├── .eslintignore ├── .husky └── pre-commit ├── src ├── assets │ ├── test.txt │ └── images │ │ ├── icon16.png │ │ ├── icon24.png │ │ ├── icon32.png │ │ ├── icon48.png │ │ ├── icon128.png │ │ └── logo.svg ├── background.ts ├── utils │ ├── network.ts │ ├── bigModule.ts │ └── mount.ts ├── pages │ ├── options │ │ ├── index.tsx │ │ └── styles.scss │ └── popup │ │ ├── styles.scss │ │ └── index.tsx ├── components │ └── AnnoyingPopup │ │ ├── styles.scss │ │ └── index.tsx ├── declarations.d.ts └── contentscripts │ └── example.tsx ├── declarations.d.ts ├── .eslintrc.cjs ├── tsconfig.json ├── LICENSE ├── .gitignore ├── package.json ├── README.md └── webpack.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | build_helpers/ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn pre-commit 5 | -------------------------------------------------------------------------------- /src/assets/test.txt: -------------------------------------------------------------------------------- 1 | This is content of test.txt file. You can import content directly or import assets's URL. -------------------------------------------------------------------------------- /src/assets/images/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OlegWock/webpack-react-web-extension-template/HEAD/src/assets/images/icon16.png -------------------------------------------------------------------------------- /src/assets/images/icon24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OlegWock/webpack-react-web-extension-template/HEAD/src/assets/images/icon24.png -------------------------------------------------------------------------------- /src/assets/images/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OlegWock/webpack-react-web-extension-template/HEAD/src/assets/images/icon32.png -------------------------------------------------------------------------------- /src/assets/images/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OlegWock/webpack-react-web-extension-template/HEAD/src/assets/images/icon48.png -------------------------------------------------------------------------------- /src/assets/images/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OlegWock/webpack-react-web-extension-template/HEAD/src/assets/images/icon128.png -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | 3 | console.log("I'm background worker!"); 4 | browser.action.setBadgeText({ 5 | text: 'Pew!', 6 | }); -------------------------------------------------------------------------------- /src/utils/network.ts: -------------------------------------------------------------------------------- 1 | export const loadTextAsset = async (url: string): Promise => { 2 | const response = await fetch(url); 3 | return response.text(); 4 | }; 5 | -------------------------------------------------------------------------------- /src/utils/bigModule.ts: -------------------------------------------------------------------------------- 1 | export const lazyFunction = () => { 2 | console.log(`I'm lazy loaded function!`); 3 | }; 4 | 5 | export const lazyAlert = () => { 6 | alert('Lazy alerty yo!'); 7 | }; -------------------------------------------------------------------------------- /declarations.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import * as browser from 'webextension-polyfill'; 3 | 4 | declare module 'webextension-polyfill' { 5 | namespace Manifest { 6 | interface WebExtensionManifestWebAccessibleResourcesC2ItemType { 7 | use_dynamic_url?: boolean; 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/pages/options/index.tsx: -------------------------------------------------------------------------------- 1 | import { injectStyles, mountPage, setPageTitle } from '@utils/mount'; 2 | 3 | import style from './styles.scss'; 4 | 5 | const Options = () => { 6 | return
Hello! I'm extension's options page. Nice to meet you.
; 7 | }; 8 | 9 | injectStyles([style]); 10 | mountPage(); 11 | setPageTitle('Options'); 12 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint'], 5 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 6 | rules: { 7 | 'react/jsx-uses-react': 'off', 8 | 'react/react-in-jsx-scope': 'off', 9 | '@typescript-eslint/ban-ts-comment': ['error', { 'ts-ignore': 'allow-with-description' }], 10 | }, 11 | globals: { 12 | chrome: true, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/pages/popup/styles.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200;0,400;0,700;1,200;1,400;1,700&display=swap'); 2 | 3 | body { 4 | font-family: 'Nunito', sans-serif; 5 | color: #333; 6 | } 7 | 8 | .Popup { 9 | width: 400px; 10 | height: 300px; 11 | display: flex; 12 | flex-direction: column; 13 | justify-content: center; 14 | align-items: center; 15 | font-size: 22px; 16 | 17 | .text-wrapper { 18 | width: 80%; 19 | text-align: center; 20 | margin-top: 16px; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/AnnoyingPopup/styles.scss: -------------------------------------------------------------------------------- 1 | .AnnoyingPopup { 2 | $width: 800px; 3 | $height: 600px; 4 | font-family: 'Nunito', sans-serif; 5 | color: #333; 6 | position: absolute; 7 | top: calc(50% - $height / 2); 8 | left: calc(50% - $width / 2); 9 | width: $width; 10 | height: $height; 11 | background: white; 12 | display: flex; 13 | flex-direction: column; 14 | justify-content: center; 15 | align-items: center; 16 | font-size: 22px; 17 | box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 12px; 18 | border: 6px solid coral; 19 | padding: 16px; 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/options/styles.scss: -------------------------------------------------------------------------------- 1 | body, 2 | html, 3 | main { 4 | height: 100%; 5 | margin: 0; 6 | } 7 | 8 | main { 9 | background: radial-gradient(circle, rgba(32, 199, 160, 1) 0%, rgba(35, 133, 110, 1) 100%); 10 | background-position: 75% 75%; 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | } 15 | 16 | .Options { 17 | width: 900px; 18 | min-height: 600px; 19 | display: flex; 20 | justify-content: center; 21 | align-items: center; 22 | font-size: 22px; 23 | box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 12px; 24 | background: white; 25 | } 26 | -------------------------------------------------------------------------------- /src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@assets/*' { 2 | const url: string; 3 | export default url; 4 | } 5 | 6 | declare module '*?raw' { 7 | const content: string; 8 | export default content; 9 | } 10 | 11 | declare module '*.scss' { 12 | const content: string; 13 | export default content; 14 | } 15 | 16 | declare module '*.sass' { 17 | const content: string; 18 | export default content; 19 | } 20 | 21 | declare module '*.css' { 22 | const content: string; 23 | export default content; 24 | } 25 | 26 | declare const X_MODE: 'development' | 'production'; 27 | declare const X_BROWSER: 'chrome'; 28 | -------------------------------------------------------------------------------- /src/pages/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import { injectStyles, mountPage } from '@utils/mount'; 2 | import logo from '@assets/images/icon128.png'; 3 | import style from './styles.scss'; 4 | import browser from 'webextension-polyfill'; 5 | 6 | const Popup = () => { 7 | return ( 8 |
9 | 10 |
Hello! I'm extension's popup. Nice to meet you.
11 |
12 | ); 13 | }; 14 | 15 | injectStyles([style]); 16 | mountPage(); 17 | console.log("I'm popup", browser); 18 | 19 | import('@utils/bigModule').then(module => module.lazyFunction()); 20 | -------------------------------------------------------------------------------- /src/components/AnnoyingPopup/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { createInjectableComponent } from 'inject-react-anywhere'; 3 | import logo from '@assets/images/icon128.png'; 4 | import styles from './styles.scss'; 5 | 6 | interface AnnoyingPopupProps { 7 | content: ReactNode; 8 | } 9 | 10 | const AnnoyingPopupComponent = ({ content }: AnnoyingPopupProps) => { 11 | return ( 12 |
13 | 14 | {content} 15 |
16 | ); 17 | }; 18 | 19 | export const AnnoyingPopup = createInjectableComponent(AnnoyingPopupComponent, { 20 | styles: [styles], 21 | }); 22 | -------------------------------------------------------------------------------- /src/utils/mount.ts: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | 3 | export const injectStyles = (styles: string[], into?: HTMLElement) => { 4 | if (!into) into = document.head; 5 | const combined = styles.join('\n'); 6 | const styleTag = document.createElement('style'); 7 | styleTag.append(document.createTextNode(combined)); 8 | into.append(styleTag); 9 | }; 10 | 11 | export const mountPage = (element: JSX.Element) => { 12 | const node = document.getElementById('root'); 13 | if (!node) { 14 | throw new Error('Called mountPage in invalid context'); 15 | } 16 | const root = createRoot(node); 17 | root.render(element); 18 | 19 | return () => { 20 | root.unmount(); 21 | }; 22 | }; 23 | 24 | export const setPageTitle = (title: string) => { 25 | document.title = title; 26 | }; 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es6", "es2019", "dom", "dom.iterable"], 4 | "baseUrl": ".", 5 | "paths": { 6 | "@utils/*": ["./src/utils/*"], 7 | "@components/*": ["./src/components/*"], 8 | "@assets/*": ["./src/assets/*"] 9 | }, 10 | "outDir": "./dist/", 11 | "noImplicitAny": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strictNullChecks": true, 14 | "module": "ESNext", 15 | "target": "ESNext", 16 | "jsx": "preserve", // Will be compiled with Babel anyway 17 | "allowJs": true, 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true 20 | }, 21 | 22 | "ts-node": { 23 | "compilerOptions": { 24 | "module": "CommonJS" 25 | }, 26 | "files": true, 27 | "include": ["declarations.d.ts"] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Oleh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/contentscripts/example.tsx: -------------------------------------------------------------------------------- 1 | import { AnnoyingPopup } from '@components/AnnoyingPopup'; 2 | import txtEmbedded from '@assets/test.txt?raw'; 3 | import { loadTextAsset } from '@utils/network'; 4 | import { injectComponent } from 'inject-react-anywhere'; 5 | import browser from 'webextension-polyfill'; 6 | import v18 from 'inject-react-anywhere/v18'; 7 | import txt from '@assets/test.txt'; 8 | 9 | 10 | const main = async () => { 11 | const txtAssetContent = await loadTextAsset(txt); 12 | const controller = await injectComponent( 13 | AnnoyingPopup, 14 | { 15 | content: ( 16 |
17 | 18 |

This is demonstration of content script which injects React component on 3rd party site.

19 |

{txtAssetContent}

20 |

This is same content, but emedded directly in source code:

21 |

{txtEmbedded}

22 |
23 | ), 24 | }, 25 | { 26 | mountStrategy: v18, 27 | } 28 | ); 29 | 30 | document.body.append(controller.shadowHost); 31 | }; 32 | 33 | main(); 34 | console.log('Hello from extension script', browser.runtime.id); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | .DS_Store -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack-react-web-extension-template", 3 | "description": "Template to quickly bootstrap your react web extension", 4 | "version": "1.2.0", 5 | "repository": "git@github.com:OlegWock/webpack-react-web-extension-template.git", 6 | "author": "OlegWock ", 7 | "license": "MIT", 8 | "scripts": { 9 | "dev": "webpack build --config webpack.config.ts --env mode=development", 10 | "production": "webpack build --config webpack.config.ts --env mode=production", 11 | "watch": "webpack watch --config webpack.config.ts", 12 | "prepare": "husky install", 13 | "lint": "eslint .", 14 | "pre-commit": "run-p lint" 15 | }, 16 | "browserslist": ">0.25% and Chrome > 80", 17 | "dependencies": { 18 | "@babel/core": "^7.22.9", 19 | "@babel/preset-env": "^7.22.9", 20 | "@babel/preset-react": "^7.22.5", 21 | "@types/node": "^18.0.4", 22 | "@types/react": "^18.2.18", 23 | "@types/react-dom": "^18.2.7", 24 | "@types/webextension-polyfill": "^0.10.0", 25 | "@typescript-eslint/eslint-plugin": "^6.2.1", 26 | "@typescript-eslint/parser": "^6.2.1", 27 | "@typescript-eslint/typescript-estree": "^6.2.1", 28 | "babel-loader": "^8.2.5", 29 | "clean-webpack-plugin": "^4.0.0", 30 | "copy-webpack-plugin": "^11.0.0", 31 | "css-loader": "^6.7.1", 32 | "file-loader": "^6.2.0", 33 | "filemanager-webpack-plugin": "^7.0.0", 34 | "inject-react-anywhere": "^1.0.0", 35 | "react": "^18.2.0", 36 | "react-dom": "^18.2.0", 37 | "sass": "^1.53.0", 38 | "sass-loader": "^13.0.2", 39 | "string-replace-loader": "^3.1.0", 40 | "terser-webpack-plugin": "^5.3.3", 41 | "to-string-loader": "^1.2.0", 42 | "ts-loader": "^9.4.2", 43 | "ts-node": "^10.9.1", 44 | "typescript": "^5.1.6", 45 | "walk-sync": "^3.0.0", 46 | "webextension-polyfill": "^0.10.0", 47 | "webpack": "^5.75.0", 48 | "webpack-cli": "^5.0.1" 49 | }, 50 | "devDependencies": { 51 | "eslint": "^8.46.0", 52 | "husky": "^8.0.3", 53 | "npm-run-all": "^4.1.5" 54 | }, 55 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 56 | } 57 | -------------------------------------------------------------------------------- /src/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |

5 | Webpack React WebExtension template 6 |

7 | 8 | Yes, yet another template. But made with love! And some cool features. 9 | 10 | ## Features 11 | 12 | ### TypeScript out of the box 13 | 14 | TypeScript automatically compiled into JS and then processed by babel. 15 | 16 | ### Automatic optimization of shared dependencies 17 | 18 | Webpack will automatically extract any dependencies shared by 2 or more entry points (i.e. pages, contentscript or background page/worker) into separate file instead of including it with each entrypoint. Those files are loaded automatically, so you don't need to include them in your manifest (but you `chunks` folder should be in `web_accessible_resources`). 19 | 20 | ### Support for dynamic imports 21 | 22 | Dynamic imports work out of the box for pages, contentscripts and background page/worker. This allows posponing loading and parsing some code to time when you actually need it: 23 | 24 | ```ts 25 | import('@utils/bigModule').then(module => module.lazyFunction()); 26 | ``` 27 | 28 | ### SCSS/SASS support 29 | 30 | Just create new SASS or SCSS file and import it, no adjustments needed. 31 | 32 | ### Easily inject React components on pages 33 | 34 | This templates comes wih [inject-react-anywhere](https://github.com/OlegWock/inject-react-anywhere) which enables you to easily inject your React componts on 3rd-party sites (includes `styled-components` and `emotion` support) 35 | 36 | ### Automatic discovery of entry points 37 | 38 | Just create new file in `contentscripts` or `pages` directory (or sub-directory) and it automatically will be picked up and compiled. No need to update webpack config (but you still need to update manifest). And you don't need to create html files for each page, webpack will do that for you. 39 | 40 | ### All libraries and shared code in separate file 41 | 42 | Webpack configured to move all shared code into two chunks: UI-related and everything else. This way you're not increasing size of your extension too much. Separating code into UI-related and everything else allows us to not include React in friends into background worker. 43 | 44 | ### Automatic translation of assets imports 45 | 46 | All content of `assets` folder is copied into distribution version without changes. And when you import any file from this folder, webpack automatically translates it to `chrome.runtime.getURL()` call, so you can do something like this: 47 | 48 | ```tsx 49 | import logo from '@assets/images/icon128.png'; 50 | 51 | const Popup = () => { 52 | return ( 53 |
54 | 55 |
56 | ); 57 | }; 58 | ``` 59 | 60 | ### Eslint and git hook 61 | 62 | Do not let bad code slip into repo! 63 | 64 | ## How to start 65 | 66 | Clone this repository: 67 | 68 | ```bash 69 | git clone git@github.com:OlegWock/webpack-react-web-extension-template.git project-name 70 | cd project-name 71 | ``` 72 | 73 | Change info in `package.json`. You want to change `name`, `description`, `author`, `repository` and other fields. 74 | 75 | Install dependencies: 76 | 77 | ```bash 78 | yarn install 79 | ``` 80 | 81 | Compile extension: 82 | 83 | ```bash 84 | yarn dev 85 | ``` 86 | 87 | You'll find compiled version in `dist/chrome`. You can now load it into Chrome. That's all. At leas that's minimal example. Let's take a closer look on other features. 88 | 89 | ### Development and production 90 | 91 | There is two commands to compile extension `dev` and `production`. They do mostly same, but slightly adjust webpack config. For example, production version doesn't have any sourcemaps. You can access this value in code using `X_MODE` variable (without any imports). It will be either `development` or `production`. 92 | 93 | ```javascript 94 | if (X_MODE === 'development') { 95 | // Enable more detailed logging here 96 | } 97 | ``` 98 | 99 | ### Watch 100 | 101 | `yarn watch` will run webpack in watch mode, which will compile code as you type and can significatly speedup development. However, note that changes to webpack config (and thus manifest too) aren't loaded by webpack, you'll need to restart it. 102 | 103 | ### Manifest 104 | 105 | You might noticed that there is no manifest.json in source files. Manifes in generated on the fly by Webpack. You can find related code in `webpack.config.ts` in `generateManifest` function. When you need to put any changes to manifest – it's right place to do so. Note: if you're runing webpack in watch mode, you'll need to restart it after making changes to manifest. 106 | 107 | ### Background worker 108 | 109 | Background worker doesn't require any configuration and should work out of the box. 110 | 111 | ### Pages 112 | 113 | This folder contains scripts each of which will be treated as separate entrypoint (thus, it won't go into common chunk but to separate output file) and for each script there will be html file created with the same name where script will be included. This is particularly handy to make, well, pages. Good examples are popup and options page. But you might as well want to implement welcome page which will be opened on install or just a separate 'web app' inside extension for user to use. 114 | 115 | ### Contentscripts 116 | 117 | Contentscripts are discovered automatically (just like pages) and compiled into `contentscripts` folder in dist. You, however, need to manually adjust manifest to enable your contentscript for desired web-site. 118 | 119 | ### Components and utils 120 | 121 | These folders are for shared code. You can organize them in any structure to your liking. 122 | 123 | ### Assets 124 | 125 | Content of this folder will be copied without any processing. However, if you import any file from this folder in your code it will be replaced with call to `chrome.runtime.getURL`, so you can use it directly as `src` of image for example. If you need to get asset's content, you can use fetch to load assets from URL. See examples in [`components/AnnoyingPopup/index.tsx`](src/components/AnnoyingPopup/index.tsx). 126 | 127 | ### Raw imports 128 | 129 | It's possible to import content of any file directly, without any processing. This way content will be emedded directly into javascript file. Just add `?raw` to any import and 130 | 131 | ```js 132 | import txtContent from '@assets/test.txt?raw'; 133 | ``` 134 | 135 | ### Import aliases 136 | 137 | There is three import aliases out of the box (but you can add your own): `@assets`, `@components` and `@utils`. They used to avoid cluttered imports like this 138 | 139 | ```js 140 | import smth from '../../../../../utils/smth'; 141 | 142 | // Instead you now can do something like 143 | import smth from '@utils/smth'; 144 | ``` 145 | 146 | If you want to add your own alias, you need to include it in `webpack.config.ts` (used by webpack for JS files), look for `alias` keyword and in `tsconfig.json` (used by TS and your code editor), look for `paths` field. 147 | 148 | ### webextension-polyfill 149 | 150 | `webextension-polyfill` provides convenient wrapper for `chrome.*` API which uses promises instead of callbacks. This in turn allows you to utilize power of async/await and write elegant async code instead of callbacks hell. 151 | 152 | ```js 153 | import browser from 'webextension-polyfill'; 154 | 155 | const main = async () => { 156 | await browser.storage.local.set({ test: 11 }); 157 | console.log('Storage updated'); 158 | }; 159 | 160 | main(); 161 | ``` 162 | 163 | ### ESLint 164 | 165 | Run `yarn lint` to lint them using ESLint. 166 | 167 | ## Adjustments 168 | 169 | ### I don't need options/popup page 170 | 171 | Just remove its folder in `src/pages` and remove it from manifest in webpack config. 172 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import * as walkSync from 'walk-sync'; 4 | import * as webpack from 'webpack'; 5 | import * as TerserPlugin from 'terser-webpack-plugin'; 6 | // @ts-ignore No declarations for this module! 7 | import * as CopyPlugin from 'copy-webpack-plugin'; 8 | import { CleanWebpackPlugin } from 'clean-webpack-plugin'; 9 | import * as FileManagerPlugin from 'filemanager-webpack-plugin'; 10 | import { 11 | createPathsObject, 12 | joinPath, 13 | scriptName, 14 | generatePageContentForScript, 15 | scriptExtensions, 16 | } from './build_helpers/webpack-utils'; 17 | import WebExtensionChuckLoaderRuntimePlugin from './build_helpers/dynamic_import_plugin/ChunkLoader'; 18 | import ServiceWorkerEntryPlugin from './build_helpers/dynamic_import_plugin/ServiceWorkerPlugin'; 19 | import { GenerateFilePlugin } from './build_helpers/GenerateFilePlugin'; 20 | import type { Manifest } from 'webextension-polyfill'; 21 | import { version, name, description, author } from './package.json'; 22 | 23 | 24 | /* eslint-disable @typescript-eslint/no-unused-vars */ 25 | 26 | interface WebpackEnvs { 27 | WEBPACK_WATCH: boolean; 28 | mode?: 'development' | 'production'; 29 | targetBrowser?: 'chrome'; 30 | } 31 | 32 | const generateManifest = ( 33 | mode: Exclude, 34 | targetBrowser: Exclude, 35 | paths: ReturnType, 36 | ) => (getFilesForEntrypoint: (name: string) => string[]) => { 37 | const manifest = { 38 | name: name, 39 | description: description, 40 | version: version, 41 | author: author, 42 | manifest_version: 3, 43 | background: { 44 | service_worker: 'background.js', 45 | }, 46 | icons: { 47 | '16': 'assets/images/icon16.png', 48 | '32': 'assets/images/icon32.png', 49 | '48': 'assets/images/icon48.png', 50 | '128': 'assets/images/icon128.png', 51 | }, 52 | action: { 53 | default_icon: { 54 | '16': 'assets/images/icon16.png', 55 | '24': 'assets/images/icon24.png', 56 | '32': 'assets/images/icon32.png', 57 | }, 58 | default_title: 'Click me!', 59 | default_popup: '/pages/popup/index.html', 60 | }, 61 | options_ui: { 62 | page: '/pages/options/index.html', 63 | open_in_tab: true, 64 | }, 65 | 66 | permissions: ['storage'], 67 | 68 | host_permissions: ['*://*.example.com/*'], 69 | 70 | content_scripts: [ 71 | { 72 | matches: ['*://*.example.com/*'], 73 | js: [ 74 | '/contentscripts/example.js', 75 | ], 76 | }, 77 | ], 78 | web_accessible_resources: [ 79 | { 80 | resources: [`/${paths.dist.assets}/*`], 81 | matches: [''], 82 | use_dynamic_url: false, 83 | }, 84 | { 85 | resources: [`/${paths.dist.chunks}/*`], 86 | matches: [''], 87 | // We'd prefer to use dynamic urls, but it's not currenly possible because of bug in Chrome 130+ 88 | // https://issues.chromium.org/issues/363027634 89 | use_dynamic_url: false, 90 | }, 91 | ], 92 | } satisfies Manifest.WebExtensionManifest; 93 | 94 | return JSON.stringify(manifest, null, 4); 95 | }; 96 | 97 | const baseSrc = './src'; 98 | const baseDist = './dist'; 99 | 100 | const config = async (env: WebpackEnvs): Promise => { 101 | const { mode = 'development', targetBrowser = 'chrome', WEBPACK_WATCH } = env; 102 | 103 | const paths = createPathsObject(baseSrc, joinPath(baseDist, targetBrowser)); 104 | 105 | const pageTemplate = fs.readFileSync(paths.src.pageHtmlTemplate, { 106 | encoding: 'utf-8', 107 | }); 108 | 109 | const entries: { [id: string]: string } = { 110 | backgroundScript: paths.src.background, 111 | }; 112 | const outputs: { [id: string]: string } = { 113 | backgroundScript: paths.dist.background, 114 | }; 115 | 116 | const generateFileInvocations: GenerateFilePlugin[] = []; 117 | 118 | const pages = walkSync(paths.src.pages, { 119 | globs: scriptExtensions.map((ext) => '**/*' + ext), 120 | directories: false, 121 | }); 122 | console.log('Pages:', pages); 123 | pages.forEach((page) => { 124 | const cleanName = scriptName(page); 125 | entries[cleanName] = joinPath(paths.src.pages, page); 126 | outputs[cleanName] = joinPath(paths.dist.pages, cleanName + '.js'); 127 | 128 | generateFileInvocations.push( 129 | new GenerateFilePlugin({ 130 | outPath: joinPath(paths.dist.pages, `${cleanName}.html`), 131 | generate(getFilesForEntrypoint) { 132 | return generatePageContentForScript(pageTemplate, { 133 | scripts: getFilesForEntrypoint(cleanName) 134 | .map((url) => { 135 | return ``; 136 | }) 137 | .join('\n'), 138 | }); 139 | }, 140 | }) 141 | ); 142 | }); 143 | 144 | // TODO: somehow automatically inject these in generated manifest? 145 | const contentscripts = walkSync(paths.src.contentscripts, { 146 | globs: scriptExtensions.map((ext) => '**/*' + ext), 147 | directories: false, 148 | }); 149 | console.log('Content scripts:', contentscripts); 150 | contentscripts.forEach((cs) => { 151 | const cleanName = scriptName(cs); 152 | entries[cleanName] = joinPath(paths.src.contentscripts, cs); 153 | outputs[cleanName] = joinPath(paths.dist.contentscripts, cleanName + '.js'); 154 | }); 155 | 156 | // @ts-expect-error There is some issue with types provided with FileManagerPlugin and CJS/ESM imports 157 | let zipPlugin: FileManagerPlugin[] = []; 158 | if (!WEBPACK_WATCH) { 159 | zipPlugin = [ 160 | // @ts-expect-error Same as above 161 | new FileManagerPlugin({ 162 | events: { 163 | onEnd: { 164 | archive: [ 165 | { 166 | source: paths.dist.base, 167 | destination: `${baseDist}/${name}-${targetBrowser}-${mode}-v${version}.zip`, 168 | }, 169 | ], 170 | }, 171 | }, 172 | }), 173 | ]; 174 | } 175 | 176 | const babelOptions = { 177 | presets: [ 178 | ['@babel/preset-env', { 179 | targets: { 180 | chrome: 90, 181 | firefox: 90, 182 | safari: 14, 183 | } 184 | }], 185 | [ 186 | '@babel/preset-react', 187 | { 188 | runtime: 'automatic', 189 | development: mode === 'development', 190 | }, 191 | ], 192 | ], 193 | }; 194 | 195 | return { 196 | mode: mode, 197 | devtool: mode === 'development' ? 'inline-source-map' : false, 198 | resolve: { 199 | alias: { 200 | '@utils': path.resolve(__dirname, paths.src.utils), 201 | '@components': path.resolve(__dirname, paths.src.components), 202 | '@assets': path.resolve(__dirname, paths.src.assets), 203 | }, 204 | 205 | modules: [path.resolve(__dirname, paths.src.base), 'node_modules'], 206 | extensions: ['*', '.js', '.jsx', '.ts', '.tsx'], 207 | }, 208 | 209 | entry: entries, 210 | output: { 211 | filename: (pathData, assetInfo) => { 212 | if (!pathData.chunk) { 213 | throw new Error('pathData.chunk not defined for some reason'); 214 | } 215 | 216 | const predefinedName = outputs[pathData.chunk.name || '']; 217 | if (predefinedName) return predefinedName; 218 | const filename = (pathData.chunk.name || pathData.chunk.id) + '.js'; 219 | return path.join(paths.dist.chunks, filename); 220 | }, 221 | chunkFilename: `${paths.dist.chunks}/[id].js`, 222 | chunkFormat: 'array-push', 223 | chunkLoadTimeout: 5000, 224 | chunkLoading: 'jsonp', 225 | path: path.resolve(__dirname, paths.dist.base), 226 | publicPath: '/', 227 | environment: { 228 | dynamicImport: true, 229 | } 230 | }, 231 | 232 | module: { 233 | rules: [ 234 | { 235 | test: /\.(ts|tsx)$/, 236 | resourceQuery: { not: [/raw/] }, 237 | include: path.resolve(__dirname, paths.src.base), 238 | use: [ 239 | { 240 | loader: 'babel-loader', 241 | options: babelOptions, 242 | }, 243 | { 244 | loader: 'ts-loader', 245 | }, 246 | ], 247 | }, 248 | { 249 | test: /\.(js|jsx)$/, 250 | resourceQuery: { not: [/raw/] }, 251 | include: path.resolve(__dirname, paths.src.base), 252 | use: { 253 | loader: 'babel-loader', 254 | options: babelOptions, 255 | }, 256 | }, 257 | { 258 | test: /\.s[ac]ss$/i, 259 | resourceQuery: { not: [/raw/] }, 260 | use: [ 261 | { 262 | loader: 'to-string-loader', 263 | }, 264 | { 265 | loader: 'css-loader', 266 | options: { 267 | url: false, 268 | }, 269 | }, 270 | { 271 | loader: 'sass-loader', 272 | }, 273 | ], 274 | }, 275 | { 276 | test: /\.css$/, 277 | use: [ 278 | { 279 | loader: 'to-string-loader', 280 | }, 281 | { 282 | loader: 'css-loader', 283 | options: { 284 | url: false, 285 | }, 286 | }, 287 | ], 288 | }, 289 | { 290 | include: path.resolve(__dirname, paths.src.assets), 291 | loader: 'file-loader', 292 | resourceQuery: { not: [/raw/] }, 293 | options: { 294 | name: '[path][name].[ext]', 295 | context: paths.src.base, 296 | postTransformPublicPath: (p: string) => `(typeof browser !== 'undefined' ? browser : chrome).runtime.getURL(${p})` 297 | }, 298 | }, 299 | { 300 | resourceQuery: /raw/, 301 | type: 'asset/source', 302 | }, 303 | ], 304 | }, 305 | 306 | plugins: [ 307 | // output.clean option deletes assets generated by plugins (e.g. manifest file or .html files), so using 308 | // CleanWebpackPlugin directly to work around this 309 | new CleanWebpackPlugin({ 310 | cleanOnceBeforeBuildPatterns: ['**/*'], 311 | }), 312 | new webpack.DefinePlugin({ 313 | X_MODE: JSON.stringify(mode), 314 | X_BROWSER: JSON.stringify(targetBrowser), 315 | }), 316 | 317 | new WebExtensionChuckLoaderRuntimePlugin({backgroundWorkerEntry: targetBrowser === 'chrome' ? 'backgroundScript' : undefined}), 318 | ...(targetBrowser === 'chrome' ? [new ServiceWorkerEntryPlugin({}, 'backgroundScript')] : []), 319 | 320 | // TODO: would be great to generate manifest after chunks are compiled and add initial chunks for each 321 | // entrypoint directly to manifest to allow them to be loaded and parsed in paralel. Same for generated pages 322 | ...generateFileInvocations, 323 | new GenerateFilePlugin({ 324 | outPath: paths.dist.manifest, 325 | generate: generateManifest(mode, targetBrowser, paths) 326 | }), 327 | 328 | // Part of files will be already copied by browser-runtime-geturl-loader, but not all (if you don't 329 | // import asset in code, it's not copied), so we need to do this with addiitonal plugin 330 | new CopyPlugin({ 331 | patterns: [ 332 | { 333 | from: `**`, 334 | context: paths.src.assets, 335 | to: ({ context, absoluteFilename }) => { 336 | const assetAbsolutePath = path.resolve(paths.src.assets); 337 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 338 | return path.join(paths.dist.assets, absoluteFilename!.replace(assetAbsolutePath, '')); 339 | }, 340 | }, 341 | ], 342 | }), 343 | ...zipPlugin, 344 | ], 345 | 346 | optimization: { 347 | minimizer: [ 348 | new TerserPlugin({ 349 | exclude: /node_modules/i, 350 | extractComments: false, 351 | terserOptions: { 352 | compress: { 353 | defaults: false, 354 | // Uncomment next line if you would like to remove console logs in production 355 | // drop_console: mode === 'production', 356 | }, 357 | mangle: false, 358 | output: { 359 | // If we don't beautify, Chrome store likely will reject extension 360 | beautify: true, 361 | }, 362 | }, 363 | }), 364 | ], 365 | 366 | splitChunks: { 367 | chunks: 'all', 368 | automaticNameDelimiter: '-', 369 | minChunks: 2, 370 | }, 371 | }, 372 | }; 373 | }; 374 | 375 | export default config; 376 | --------------------------------------------------------------------------------