├── .changeset ├── README.md └── config.json ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── .gitignore ├── .husky └── pre-commit ├── .nvmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── extension ├── assets │ ├── icon-512.png │ ├── icon.svg │ └── logo.svg └── manifest.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public └── assets │ ├── icon-512.png │ ├── icon.svg │ └── logo.svg ├── scripts ├── client.ts ├── manifest.ts ├── prepare.ts └── utils.ts ├── shim.d.ts ├── src ├── auto-imports.d.ts ├── background │ └── main.ts ├── components │ └── Logo.tsx ├── contentScripts │ ├── index.tsx │ └── views │ │ ├── App.tsx │ │ └── style.css ├── env.ts ├── global.d.ts ├── hmr.ts ├── logic │ ├── index.ts │ └── storage.ts ├── manifest.ts ├── options │ ├── Options.tsx │ ├── index.html │ ├── main.tsx │ └── style.css ├── popup │ ├── Popup.tsx │ ├── index.html │ ├── main.tsx │ └── style.css └── styles │ ├── index.ts │ └── main.css ├── tailwind.config.js ├── tsconfig.json ├── tsup.config.ts ├── vite-mv-hmr.ts ├── vite.config.content.ts └── vite.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "linked": [], 6 | "access": "public", 7 | "baseBranch": "master", 8 | "updateInternalDependencies": "patch", 9 | "ignore": [] 10 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | public 4 | extension -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@aiou", 3 | "rules": { 4 | "@typescript-eslint/no-unused-vars": "off", 5 | "import/no-extraneous-dependencies": [ 6 | "error", 7 | { 8 | "devDependencies": [ 9 | // ignore dev scripts 10 | "**/scripts/**/*.{js,jsx,ts,tsx,cjs,mjs}", 11 | "**/*.test.{js,jsx,ts,tsx,cjs,mjs}", 12 | "**/*.spec.{js,jsx,ts,tsx,mjs,cjs}", 13 | "**/*.config**.{js,jsx,ts,tsx,cjs,mjs}", 14 | "src/manifest.ts", 15 | // service worker need bundle all code into one files 16 | "src/background/main.ts", 17 | // ignore require third packages in .eslintrc.* e.g. eslint-define-config 18 | "**/.eslintrc.{js,cjs,mjs}" 19 | ] 20 | } 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: JiangWeixian 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - v2 6 | - v3 7 | env: 8 | CI: true 9 | jobs: 10 | version: 11 | timeout-minutes: 15 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | packages: write 16 | pull-requests: write 17 | steps: 18 | - name: checkout code repository 19 | uses: actions/checkout@v2 20 | with: 21 | fetch-depth: 0 22 | - name: setup node.js 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: 14 26 | - name: install pnpm 27 | run: npm i pnpm@7.29.3 -g 28 | - name: install dependencies 29 | run: pnpm install --frozen-lockfile=false 30 | - name: create and publish versions 31 | uses: changesets/action@master 32 | with: 33 | version: pnpm ci:version 34 | commit: "chore: update versions" 35 | title: "chore: update versions" 36 | publish: pnpm ci:publish 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Node.gitignore 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | .pnpm-store 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Microbundle cache 59 | .rpt2_cache/ 60 | .rts2_cache_cjs/ 61 | .rts2_cache_es/ 62 | .rts2_cache_umd/ 63 | 64 | # Optional REPL history 65 | .node_repl_history 66 | 67 | # Output of 'npm pack' 68 | *.tgz 69 | 70 | # Yarn Integrity file 71 | .yarn-integrity 72 | 73 | # dotenv environment variables file 74 | .env 75 | .env.test 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | .parcel-cache 80 | 81 | # Next.js build output 82 | .next 83 | out 84 | 85 | # Nuxt.js build / generate output 86 | .nuxt 87 | dist 88 | 89 | # Gatsby files 90 | .cache/ 91 | # Comment in the public line in if your project uses Gatsby and not Next.js 92 | # https://nextjs.org/blog/next-9-1#public-directory-support 93 | # public 94 | 95 | # vuepress build output 96 | .vuepress/dist 97 | 98 | # Serverless directories 99 | .serverless/ 100 | 101 | # FuseBox cache 102 | .fusebox/ 103 | 104 | # DynamoDB Local files 105 | .dynamodb/ 106 | 107 | # TernJS port file 108 | .tern-port 109 | 110 | # Stores VSCode versions used for testing VSCode extensions 111 | .vscode-test 112 | 113 | # yarn v2 114 | .yarn/cache 115 | .yarn/unplugged 116 | .yarn/build-state.yml 117 | .yarn/install-state.gz 118 | .pnp.* 119 | 120 | ### macOS.gitignore 121 | # General 122 | .DS_Store 123 | .AppleDouble 124 | .LSOverride 125 | 126 | # Icon must end with two \r 127 | Icon 128 | 129 | 130 | # Thumbnails 131 | ._* 132 | 133 | # Files that might appear in the root of a volume 134 | .DocumentRevisions-V100 135 | .fseventsd 136 | .Spotlight-V100 137 | .TemporaryItems 138 | .Trashes 139 | .VolumeIcon.icns 140 | .com.apple.timemachine.donotpresent 141 | 142 | # Directories potentially created on remote AFP share 143 | .AppleDB 144 | .AppleDesktop 145 | Network Trash Folder 146 | Temporary Items 147 | .apdisk 148 | extension 149 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | pnpm lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.13.2 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @aiou/webext-template 2 | 3 | ## 0.2.3 4 | 5 | ### Patch Changes 6 | 7 | - 49495dc: watch components & logic & styles folders 8 | 9 | ## 0.2.2 10 | 11 | ### Patch Changes 12 | 13 | - bb2bc04: update workflow permissions 14 | - bb2bc04: require node16 & disable minify on dev mode 15 | 16 | ## 0.2.1 17 | 18 | ### Patch Changes 19 | 20 | - 2c964b7: bundle all packages into background bundle 21 | 22 | ## 0.2.0 23 | 24 | ### Minor Changes 25 | 26 | - b326443: manifest v3 27 | 28 | ## 0.1.2 29 | 30 | ### Patch Changes 31 | 32 | - 26afae9: fix hmr in option pages 33 | 34 | ## 0.1.1 35 | 36 | ### Patch Changes 37 | 38 | - 64854a8: fill up pkg info 39 | 40 | ## 0.1.0 41 | 42 | ### Minor Changes 43 | 44 | - 4b30d18: meet vite + react + webext 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 JW 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @aiou/webext-template 2 | *a fork version of [vitesse-webext](https://github.com/antfu/vitesse-webext), but with react* 3 | 4 | *thanks to awesome work https://github.com/xlzy520/vitesse-webext/tree/refactor/mv3 support chrome manifest v3* 5 | 6 | A [Vite](https://vitejs.dev/) powered WebExtension ([Chrome](https://developer.chrome.com/docs/extensions/reference/), [FireFox](https://addons.mozilla.org/en-US/developers/), etc.) starter template. 7 | 8 | [![npm](https://img.shields.io/npm/v/@aiou/webext-template)](https://github.com/neo-hack/webext-template) [![GitHub](https://img.shields.io/github/license/neo-hack/webext-template)](https://github.com/neo-hack/webext-template) [![stackblitz](https://img.shields.io/badge/%E2%9A%A1%EF%B8%8Fstackblitz-online-blue)](https://github.com/neo-hack/webext-template) 9 | 10 | [Edit on StackBlitz ⚡️](https://stackblitz.com/github/JiangWeixian/templates/tree/master/packages/webext-template) 11 | 12 | ## Features 13 | 14 | - ⚡️ **Instant HMR** - use **Vite** on dev (no more refresh!) 15 | - 🌐 React 16 | - 💬 Effortless communications - powered by [`webext-bridge`](https://github.com/antfu/webext-bridge) 17 | - 🍃 [tailwindcss](https://tailwindcss.come/) - on-demand CSS utilities 18 | - 🦾 [TypeScript](https://www.typescriptlang.org/) - type safe 19 | - 🖥 Content Script - Use React even in content script 20 | - 🌍 WebExtension - isomorphic extension for Chrome, Firefox, and others 21 | - 📃 Dynamic `manifest.json` with full type support 22 | 23 | ## Pre-packed 24 | 25 | ### WebExtension Libraries 26 | 27 | - [`webextension-polyfill`](https://github.com/mozilla/webextension-polyfill) - WebExtension browser API Polyfill with types 28 | - [`webext-bridge`](https://github.com/antfu/webext-bridge) - effortlessly communication between contexts 29 | 30 | ### Dev tools 31 | 32 | - [TypeScript](https://www.typescriptlang.org/) 33 | - [pnpm](https://pnpm.js.org/) - fast, disk space efficient package manager 34 | - [esno](https://github.com/antfu/esno) - TypeScript / ESNext node runtime powered by esbuild 35 | - [npm-run-all](https://github.com/mysticatea/npm-run-all) - Run multiple npm-scripts in parallel or sequential 36 | - [web-ext](https://github.com/mozilla/web-ext) - Streamlined experience for developing web extensions 37 | 38 | ## Usage 39 | 40 | ### Folders 41 | 42 | - `src` - main source. 43 | - `contentScript` - scripts and components to be injected as `content_script` 44 | - `background` - scripts for background. 45 | - `components` - auto-imported React components that shared in popup and options page. 46 | - `styles` - styles shared in popup and options page 47 | - `manifest.ts` - manifest for the extension. 48 | - `extension` - extension package root. 49 | - `assets` - static assets. 50 | - `dist` - built files, also serve stub entry for Vite on development. 51 | - `scripts` - development and bundling helper scripts. 52 | 53 | ### Development 54 | 55 | ```bash 56 | pnpm dev 57 | ``` 58 | 59 | Then **load extension in browser with the `extension/` folder**. 60 | 61 | For Firefox developers, you can run the following command instead: 62 | 63 | ```bash 64 | pnpm start:firefox 65 | ``` 66 | 67 | `web-ext` auto reload the extension when `extension/` files changed. 68 | 69 | > While Vite handles HMR automatically in the most of the case, [Extensions Reloader](https://chrome.google.com/webstore/detail/fimgfedafeadlieiabdeeaodndnlbhid) is still recommanded for cleaner hard reloading. 70 | 71 | ### Build 72 | 73 | To build the extension, run 74 | 75 | ```bash 76 | pnpm build 77 | ``` 78 | 79 | And then pack files under `extension`, you can upload `extension.crx` or `extension.xpi` to appropriate extension store. 80 | 81 | # 82 |
83 | 84 | *built with ❤️ by [😼](https://github.com/neo-hack/templates)* 85 | 86 |
87 | -------------------------------------------------------------------------------- /extension/assets/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neo-hack/webext-template/ff9bec69223e6e274cef4981e128025520fede68/extension/assets/icon-512.png -------------------------------------------------------------------------------- /extension/assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /extension/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "@aiou/webext-template", 4 | "version": "0.1.2", 5 | "description": "webext template powered by vite and react", 6 | "action": { 7 | "default_icon": "./assets/icon-512.png", 8 | "default_popup": "./dist/popup/index.html" 9 | }, 10 | "options_ui": { 11 | "page": "./dist/options/index.html", 12 | "open_in_tab": true 13 | }, 14 | "background": { 15 | "service_worker": "./dist/background/index.mjs" 16 | }, 17 | "icons": { 18 | "16": "./assets/icon-512.png", 19 | "48": "./assets/icon-512.png", 20 | "128": "./assets/icon-512.png" 21 | }, 22 | "permissions": [ 23 | "tabs", 24 | "storage", 25 | "activeTab", 26 | "webNavigation" 27 | ], 28 | "host_permissions": [ 29 | "*://*/*" 30 | ], 31 | "content_scripts": [ 32 | { 33 | "matches": [ 34 | "http://*/*", 35 | "https://*/*" 36 | ], 37 | "js": [ 38 | "./dist/contentScripts/index.global.js" 39 | ] 40 | } 41 | ], 42 | "web_accessible_resources": [ 43 | { 44 | "resources": [ 45 | "dist/contentScripts/style.css" 46 | ], 47 | "matches": [ 48 | "" 49 | ] 50 | } 51 | ], 52 | "content_security_policy": { 53 | "extension_pages": "script-src 'self' http://localhost:3303; object-src 'self' http://localhost:3303" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@aiou/webext-template", 3 | "version": "0.2.3", 4 | "description": "webext template powered by vite and react", 5 | "keywords": [ 6 | "react", 7 | "vite", 8 | "extension", 9 | "chrome", 10 | "firefox", 11 | "aiou", 12 | "template" 13 | ], 14 | "license": "MIT", 15 | "packageManager": "pnpm@7.23.0", 16 | "homepage": "https://github.com/neo-hack/webext-template", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/neo-hack/webext-template" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/neo-hack/webext-template/issues", 23 | "email": "jiangweixian1994@gmail.com" 24 | }, 25 | "author": "JW (https://twitter.com/jiangweixian)", 26 | "scripts": { 27 | "dev": "npm run clear && cross-env NODE_ENV=development run-p dev:*", 28 | "dev:bg": "tsup --watch ./src", 29 | "dev:prepare": "esno scripts/prepare.ts", 30 | "dev:web": "vite", 31 | "dev:js": "npm run build:js -- --mode development", 32 | "build": "cross-env NODE_ENV=production run-s clear build:*", 33 | "build:web": "vite build", 34 | "build:prepare": "esno scripts/prepare.ts", 35 | "build:js": "vite build --config vite.config.content.ts", 36 | "build:bg": "tsup --config tsup.config.ts", 37 | "pack": "cross-env NODE_ENV=production run-p pack:*", 38 | "pack:zip": "rimraf extension.zip && jszip-cli add extension -o ./extension.zip", 39 | "pack:crx": "crx pack extension -o ./extension.crx", 40 | "pack:xpi": "cross-env WEB_EXT_ARTIFACTS_DIR=./ web-ext build --source-dir ./extension --filename extension.xpi --overwrite-dest", 41 | "start:chromium": "web-ext run --source-dir ./extension --target=chromium", 42 | "start:firefox": "web-ext run --source-dir ./extension --target=firefox-desktop", 43 | "clear": "rimraf extension", 44 | "lint:fix": "eslint . --fix", 45 | "prepare": "husky install", 46 | "ci:publish": "pnpm changeset publish", 47 | "ci:version": "pnpm changeset version" 48 | }, 49 | "lint-staged": { 50 | "**/**/*.{js,ts,tsx,vue,json}": ["eslint --fix"] 51 | }, 52 | "dependencies": { 53 | "document-ready": "^2.0.2", 54 | "react": "^18.2.0", 55 | "react-dom": "^18.2.0" 56 | }, 57 | "devDependencies": { 58 | "@aiou/eslint-config": "^0.7.8", 59 | "@changesets/cli": "^2.17.0", 60 | "@ffflorian/jszip-cli": "^3.1.5", 61 | "@rollup/plugin-replace": "^5.0.2", 62 | "@types/document-ready": "^2.0.0", 63 | "@types/fs-extra": "^9.0.12", 64 | "@types/node": "^16.7.13", 65 | "@types/react": "^18.0.28", 66 | "@types/react-dom": "^18.0.11", 67 | "@types/webextension-polyfill": "^0.10.0", 68 | "@vitejs/plugin-react": "^2.0.1", 69 | "autoprefixer": "^10.4.14", 70 | "chokidar": "^3.5.2", 71 | "cross-env": "^7.0.3", 72 | "crx": "^5.0.1", 73 | "cz-emoji": "^1.3.1", 74 | "eslint": "^8.36.0", 75 | "esno": "^0.9.1", 76 | "fs-extra": "^10.0.0", 77 | "husky": "^7.0.0", 78 | "kolorist": "^1.5.0", 79 | "lint-staged": "^11.1.2", 80 | "npm-run-all": "^4.1.5", 81 | "rimraf": "^3.0.2", 82 | "tailwindcss": "^3.2.7", 83 | "terser": "^5.16.6", 84 | "tsup": "^6.6.3", 85 | "typescript": "^4.4.2", 86 | "unplugin-auto-import": "^0.4.5", 87 | "vite": "^3.2.4", 88 | "web-ext": "^6.3.0", 89 | "webext-bridge": "^4.1.1", 90 | "webextension-polyfill": "^0.8.0" 91 | }, 92 | "engines": { 93 | "node": ">=16" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/assets/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neo-hack/webext-template/ff9bec69223e6e274cef4981e128025520fede68/public/assets/icon-512.png -------------------------------------------------------------------------------- /public/assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /scripts/client.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorPayload, HMRPayload, Update } from 'vite' 2 | import type { ViteHotContext } from 'vite/types/hot' 3 | import type { InferCustomEventPayload } from 'vite/types/customEvent' 4 | // Vite v3 doesn't export overlay 5 | // import { ErrorOverlay, overlayId } from 'vite/src/client/overlay' 6 | 7 | console.debug('[vite] connecting...') 8 | 9 | // use server configuration, then fallback to inference 10 | const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws' 11 | const socketHost = 'localhost:3303' 12 | const base = '/' 13 | const messageBuffer: string[] = [] 14 | const enableOverlay = true 15 | 16 | const hotModulesMap = new Map() 17 | const disposeMap = new Map void | Promise>() 18 | const pruneMap = new Map void | Promise>() 19 | const dataMap = new Map() 20 | const customListenersMap = new Map void)[]>() 21 | const ctxToListenersMap = new Map void)[]>>() 22 | 23 | let socket: WebSocket 24 | try { 25 | socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr') 26 | 27 | // Listen for messages 28 | socket.addEventListener('message', async ({ data }) => { 29 | handleMessage(JSON.parse(data)) 30 | }) 31 | 32 | // ping server 33 | socket.addEventListener('close', async ({ wasClean }) => { 34 | if (wasClean) return 35 | console.log('[vite] server connection lost. polling for restart...') 36 | await waitForSuccessfulPing() 37 | location.reload() 38 | }) 39 | } catch (error) { 40 | console.error(`[vite] failed to connect to websocket (${error}). `) 41 | } 42 | 43 | function warnFailedFetch(err: Error, path: string | string[]) { 44 | if (!err.message.match('fetch')) console.error(err) 45 | 46 | console.error( 47 | `[hmr] Failed to reload ${path}. ` + 48 | 'This could be due to syntax errors or importing non-existent ' + 49 | 'modules. (see errors above)', 50 | ) 51 | } 52 | 53 | function cleanUrl(pathname: string): string { 54 | const url = new URL(pathname, location.toString()) 55 | url.searchParams.delete('direct') 56 | return url.pathname + url.search 57 | } 58 | 59 | let isFirstUpdate = true 60 | 61 | async function handleMessage(payload: HMRPayload) { 62 | switch (payload.type) { 63 | case 'connected': 64 | console.debug('[vite] connected.') 65 | sendMessageBuffer() 66 | // proxy(nginx, docker) hmr ws maybe caused timeout, 67 | // so send ping package let ws keep alive. 68 | setInterval(() => socket.send('{"type":"ping"}'), 5000) 69 | break 70 | case 'update': 71 | notifyListeners('vite:beforeUpdate', payload) 72 | // if this is the first update and there's already an error overlay, it 73 | // means the page opened with existing server compile error and the whole 74 | // module script failed to load (since one of the nested imports is 500). 75 | // in this case a normal update won't work and a full reload is needed. 76 | if (isFirstUpdate && hasErrorOverlay()) { 77 | window.location.reload() 78 | return 79 | } else { 80 | clearErrorOverlay() 81 | isFirstUpdate = false 82 | } 83 | payload.updates.forEach((update) => { 84 | if (update.type === 'js-update') { 85 | queueUpdate(fetchUpdate(update)) 86 | } else { 87 | // css-update 88 | // this is only sent when a css file referenced with is updated 89 | const { path, timestamp } = update 90 | const searchUrl = cleanUrl(path) 91 | // can't use querySelector with `[href*=]` here since the link may be 92 | // using relative paths so we need to use link.href to grab the full 93 | // URL for the include check. 94 | const el = Array.from(document.querySelectorAll('link')).find((e) => 95 | cleanUrl(e.href).includes(searchUrl), 96 | ) 97 | if (el) { 98 | const newPath = `${base}${searchUrl.slice(1)}${ 99 | searchUrl.includes('?') ? '&' : '?' 100 | }t=${timestamp}` 101 | el.href = new URL(newPath, el.href).href 102 | } 103 | console.log(`[vite] css hot updated: ${searchUrl}`) 104 | } 105 | }) 106 | break 107 | case 'custom': { 108 | notifyListeners(payload.event, payload.data) 109 | break 110 | } 111 | case 'full-reload': 112 | notifyListeners('vite:beforeFullReload', payload) 113 | if (payload.path && payload.path.endsWith('.html')) { 114 | // if html file is edited, only reload the page if the browser is 115 | // currently on that page. 116 | const pagePath = decodeURI(location.pathname) 117 | const payloadPath = base + payload.path.slice(1) 118 | if ( 119 | pagePath === payloadPath || 120 | payload.path === '/index.html' || 121 | (pagePath.endsWith('/') && `${pagePath}index.html` === payloadPath) 122 | ) 123 | location.reload() 124 | } else { 125 | location.reload() 126 | } 127 | break 128 | case 'prune': 129 | notifyListeners('vite:beforePrune', payload) 130 | // After an HMR update, some modules are no longer imported on the page 131 | // but they may have left behind side effects that need to be cleaned up 132 | // (.e.g style injections) 133 | // TODO Trigger their dispose callbacks. 134 | payload.paths.forEach((path) => { 135 | const fn = pruneMap.get(path) 136 | if (fn) fn(dataMap.get(path)) 137 | }) 138 | break 139 | case 'error': { 140 | notifyListeners('vite:error', payload) 141 | const err = payload.err 142 | if (enableOverlay) createErrorOverlay(err) 143 | else console.error(`[vite] Internal Server Error\n${err.message}\n${err.stack}`) 144 | 145 | break 146 | } 147 | default: { 148 | const check: never = payload 149 | return check 150 | } 151 | } 152 | } 153 | 154 | function notifyListeners(event: T, data: InferCustomEventPayload): void 155 | function notifyListeners(event: string, data: any): void { 156 | const cbs = customListenersMap.get(event) 157 | if (cbs) cbs.forEach((cb) => cb(data)) 158 | } 159 | 160 | function createErrorOverlay(_err: ErrorPayload['err']) { 161 | if (!enableOverlay) return 162 | clearErrorOverlay() 163 | // document.body.appendChild(new ErrorOverlay(err)) 164 | } 165 | 166 | function clearErrorOverlay() { 167 | // document.querySelectorAll(overlayId).forEach(n => (n as ErrorOverlay).close()) 168 | } 169 | 170 | function hasErrorOverlay() { 171 | // return document.querySelectorAll(overlayId).length 172 | return false 173 | } 174 | 175 | let pending = false 176 | let queued: Promise<(() => void) | undefined>[] = [] 177 | 178 | /** 179 | * buffer multiple hot updates triggered by the same src change 180 | * so that they are invoked in the same order they were sent. 181 | * (otherwise the order may be inconsistent because of the http request round trip) 182 | */ 183 | async function queueUpdate(p: Promise<(() => void) | undefined>) { 184 | queued.push(p) 185 | if (!pending) { 186 | pending = true 187 | await Promise.resolve() 188 | pending = false 189 | const loading = [...queued] 190 | queued = [] 191 | ;(await Promise.all(loading)).forEach((fn) => fn && fn()) 192 | } 193 | } 194 | 195 | async function waitForSuccessfulPing(ms = 1000) { 196 | while (true) { 197 | try { 198 | // A fetch on a websocket URL will return a successful promise with status 400, 199 | // but will reject a networking error. 200 | await fetch(`${location.protocol}//${socketHost}`) 201 | break 202 | } catch (e) { 203 | // wait ms before attempting to ping again 204 | await new Promise((resolve) => setTimeout(resolve, ms)) 205 | } 206 | } 207 | } 208 | 209 | // https://wicg.github.io/construct-stylesheets 210 | const supportsConstructedSheet = (() => { 211 | // TODO: re-enable this try block once Chrome fixes the performance of 212 | // rule insertion in really big stylesheets 213 | // try { 214 | // new CSSStyleSheet() 215 | // return true 216 | // } catch (e) {} 217 | return false 218 | })() 219 | 220 | const sheetsMap = new Map() 221 | 222 | export function updateStyle(id: string, content: string): void { 223 | let style = sheetsMap.get(id) 224 | if (supportsConstructedSheet && !content.includes('@import')) { 225 | if (style && !(style instanceof CSSStyleSheet)) { 226 | removeStyle(id) 227 | style = undefined 228 | } 229 | 230 | if (!style) { 231 | style = new CSSStyleSheet() 232 | style.replaceSync(content) 233 | document.adoptedStyleSheets = [...document.adoptedStyleSheets, style] 234 | } else { 235 | style.replaceSync(content) 236 | } 237 | } else { 238 | if (style && !(style instanceof HTMLStyleElement)) { 239 | removeStyle(id) 240 | style = undefined 241 | } 242 | 243 | if (!style) { 244 | style = document.createElement('style') 245 | style.setAttribute('type', 'text/css') 246 | style.innerHTML = content 247 | document.head.appendChild(style) 248 | } else { 249 | style.innerHTML = content 250 | } 251 | } 252 | sheetsMap.set(id, style) 253 | } 254 | 255 | export function removeStyle(id: string): void { 256 | const style = sheetsMap.get(id) 257 | if (style) { 258 | if (style instanceof CSSStyleSheet) 259 | document.adoptedStyleSheets = document.adoptedStyleSheets.filter( 260 | (s: CSSStyleSheet) => s !== style, 261 | ) 262 | else document.head.removeChild(style) 263 | 264 | sheetsMap.delete(id) 265 | } 266 | } 267 | 268 | async function fetchUpdate({ path, acceptedPath, timestamp }: Update) { 269 | let mod = hotModulesMap.get(path) 270 | if (!mod) { 271 | mod = hotModulesMap.get(path.replace(/\.js$/, '')) 272 | 273 | if (!mod) { 274 | // In a code-splitting project, 275 | // it is common that the hot-updating module is not loaded yet. 276 | // https://github.com/vitejs/vite/issues/721 277 | return 278 | } 279 | 280 | path = path.replace(/\.js$/, '') 281 | acceptedPath = acceptedPath.replace(/\.js$/, '') 282 | } 283 | 284 | const moduleMap = new Map() 285 | const isSelfUpdate = path === acceptedPath 286 | 287 | // make sure we only import each dep once 288 | const modulesToUpdate = new Set() 289 | if (isSelfUpdate) { 290 | // self update - only update self 291 | modulesToUpdate.add(path) 292 | } else { 293 | // dep update 294 | for (const { deps } of mod.callbacks) { 295 | deps.forEach((dep) => { 296 | if (acceptedPath === dep) modulesToUpdate.add(dep) 297 | }) 298 | } 299 | } 300 | 301 | // determine the qualified callbacks before we re-import the modules 302 | const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => { 303 | return deps.some((dep) => modulesToUpdate.has(dep)) 304 | }) 305 | 306 | await Promise.all( 307 | Array.from(modulesToUpdate).map(async (dep) => { 308 | const disposer = disposeMap.get(dep) 309 | if (disposer) await disposer(dataMap.get(dep)) 310 | const [path, query] = dep.split('?') 311 | try { 312 | const newMod = await import( 313 | /* @vite-ignore */ 314 | normalizeScriptUrl(`${base + path.slice(1)}.js${query ? `_${query}` : ''}`, timestamp) 315 | ) 316 | moduleMap.set(dep, newMod) 317 | } catch (e: any) { 318 | warnFailedFetch(e, dep) 319 | } 320 | }), 321 | ) 322 | 323 | return () => { 324 | for (const { deps, fn } of qualifiedCallbacks) fn(deps.map((dep) => moduleMap.get(dep))) 325 | 326 | const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}` 327 | console.log(`[vite] hot updated: ${loggedPath}`) 328 | } 329 | } 330 | 331 | function normalizeScriptUrl(url: string, timestamp: number) { 332 | if (!url.endsWith('.js') && !url.endsWith('.mjs')) url = `${url}.js` 333 | return `${url}?t=${timestamp}` 334 | } 335 | 336 | function sendMessageBuffer() { 337 | if (socket.readyState === 1) { 338 | messageBuffer.forEach((msg) => socket.send(msg)) 339 | messageBuffer.length = 0 340 | } 341 | } 342 | 343 | interface HotModule { 344 | id: string 345 | callbacks: HotCallback[] 346 | } 347 | 348 | interface HotCallback { 349 | // the dependencies must be fetchable paths 350 | deps: string[] 351 | fn: (modules: object[]) => void 352 | } 353 | 354 | export function createHotContext(ownerPath: string): ViteHotContext { 355 | if (!dataMap.has(ownerPath)) dataMap.set(ownerPath, {}) 356 | 357 | // when a file is hot updated, a new context is created 358 | // clear its stale callbacks 359 | const mod = hotModulesMap.get(ownerPath) 360 | if (mod) mod.callbacks = [] 361 | 362 | // clear stale custom event listeners 363 | const staleListeners = ctxToListenersMap.get(ownerPath) 364 | if (staleListeners) { 365 | for (const [event, staleFns] of staleListeners) { 366 | const listeners = customListenersMap.get(event) 367 | if (listeners) { 368 | customListenersMap.set( 369 | event, 370 | listeners.filter((l) => !staleFns.includes(l)), 371 | ) 372 | } 373 | } 374 | } 375 | 376 | const newListeners = new Map() 377 | ctxToListenersMap.set(ownerPath, newListeners) 378 | 379 | function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}) { 380 | const mod: HotModule = hotModulesMap.get(ownerPath) || { 381 | id: ownerPath, 382 | callbacks: [], 383 | } 384 | mod.callbacks.push({ 385 | deps, 386 | fn: callback, 387 | }) 388 | hotModulesMap.set(ownerPath, mod) 389 | } 390 | 391 | const hot: ViteHotContext = { 392 | get data() { 393 | console.log('ViteHotContext data', { dataMap, ownerPath }) 394 | return dataMap.get(ownerPath) 395 | }, 396 | 397 | accept(deps?: any, callback?: any) { 398 | if (typeof deps === 'function' || !deps) { 399 | // self-accept: hot.accept(() => {}) 400 | acceptDeps([ownerPath], ([mod]) => deps && deps(mod)) 401 | } else if (typeof deps === 'string') { 402 | // explicit deps 403 | acceptDeps([deps], ([mod]) => callback && callback(mod)) 404 | } else if (Array.isArray(deps)) { 405 | acceptDeps(deps, callback) 406 | } else { 407 | throw new TypeError('invalid hot.accept() usage.') 408 | } 409 | }, 410 | 411 | dispose(cb) { 412 | disposeMap.set(ownerPath, cb) 413 | }, 414 | 415 | // @ts-expect-error untyped 416 | prune(cb: (data: any) => void) { 417 | pruneMap.set(ownerPath, cb) 418 | }, 419 | 420 | // TODO 421 | 422 | decline() {}, 423 | 424 | invalidate() { 425 | // TODO should tell the server to re-perform hmr propagation 426 | // from this module as root 427 | location.reload() 428 | }, 429 | 430 | // custom events 431 | on(event, cb) { 432 | const addToMap = (map: Map) => { 433 | const existing = map.get(event) || [] 434 | existing.push(cb) 435 | map.set(event, existing) 436 | } 437 | addToMap(customListenersMap) 438 | addToMap(newListeners) 439 | }, 440 | 441 | send(event, data) { 442 | messageBuffer.push(JSON.stringify({ type: 'custom', event, data })) 443 | sendMessageBuffer() 444 | }, 445 | } 446 | 447 | return hot 448 | } 449 | 450 | /** 451 | * urls here are dynamic import() urls that couldn't be statically analyzed 452 | */ 453 | export function injectQuery(url: string, queryToInject: string): string { 454 | // skip urls that won't be handled by vite 455 | if (!url.startsWith('.') && !url.startsWith('/')) return url 456 | 457 | // can't use pathname from URL since it may be relative like ../ 458 | const pathname = url.replace(/#.*$/, '').replace(/\?.*$/, '') 459 | const { search, hash } = new URL(url, 'http://vitejs.dev') 460 | 461 | return `${pathname}?${queryToInject}${search ? `&${search.slice(1)}` : ''}${hash || ''}` 462 | } 463 | -------------------------------------------------------------------------------- /scripts/manifest.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import { getManifest } from '../src/manifest' 3 | import { r, log } from './utils' 4 | 5 | export async function writeManifest() { 6 | await fs.writeJSON(r('extension/manifest.json'), await getManifest(), { spaces: 2 }) 7 | log('PRE', 'write manifest.json') 8 | } 9 | 10 | writeManifest() 11 | -------------------------------------------------------------------------------- /scripts/prepare.ts: -------------------------------------------------------------------------------- 1 | // generate stub index.html files for dev entry 2 | import { execSync } from 'child_process' 3 | import fs from 'fs-extra' 4 | import chokidar from 'chokidar' 5 | import { r, port, isDev, log } from './utils' 6 | 7 | /** 8 | * Stub index.html to use Vite in development 9 | */ 10 | async function stubIndexHtml() { 11 | const views = ['options', 'popup'] 12 | 13 | for (const view of views) { 14 | await fs.ensureDir(r(`extension/dist/${view}`)) 15 | let data = await fs.readFile(r(`src/${view}/index.html`), 'utf-8') 16 | data = data 17 | .replace(/".\/main.(tsx?)"/g, (_match, p1) => { 18 | return `"http://localhost:${port}/${view}/main.${p1}"` 19 | }) 20 | .replace( 21 | //g, 22 | ``, 23 | ) 24 | .replace('
', '
Vite server did not start
') 25 | await fs.writeFile(r(`extension/dist/${view}/index.html`), data, 'utf-8') 26 | log('PRE', `stub ${view}`) 27 | } 28 | } 29 | 30 | function copyPublicAssets() { 31 | fs.copy(r('public/assets'), r('extension/assets')) 32 | } 33 | 34 | function writeManifest() { 35 | execSync('npx esno ./scripts/manifest.ts', { stdio: 'inherit' }) 36 | } 37 | 38 | writeManifest() 39 | copyPublicAssets() 40 | 41 | if (isDev) { 42 | stubIndexHtml() 43 | chokidar.watch(r('src/**/*.html')).on('change', () => { 44 | stubIndexHtml() 45 | }) 46 | chokidar.watch([r('src/manifest.ts'), r('package.json')]).on('change', () => { 47 | writeManifest() 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /scripts/utils.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { bgCyan, black } from 'kolorist' 3 | 4 | export const port = parseInt(process.env.PORT || '') || 3303 5 | export const r = (...args: string[]) => resolve(__dirname, '..', ...args) 6 | export const isDev = process.env.NODE_ENV !== 'production' 7 | export const isWin = process.platform === 'win32' 8 | 9 | export function log(name: string, message: string) { 10 | // eslint-disable-next-line no-console 11 | console.log(black(bgCyan(` ${name} `)), message) 12 | } 13 | -------------------------------------------------------------------------------- /shim.d.ts: -------------------------------------------------------------------------------- 1 | import { ProtocolWithReturn } from 'webext-bridge' 2 | 3 | declare module 'webext-bridge' { 4 | export interface ProtocolMap { 5 | // define message protocol types 6 | // see https://github.com/antfu/webext-bridge#type-safe-protocols 7 | 'tab-prev': { title: string | undefined } 8 | 'get-current-tab': ProtocolWithReturn<{ tabId: number }, { title: string }> 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by 'unplugin-auto-import' 2 | // We suggest you to commit this file into source control 3 | declare global { 4 | const browser: typeof import('webextension-polyfill')['default'] 5 | } 6 | export {} 7 | -------------------------------------------------------------------------------- /src/background/main.ts: -------------------------------------------------------------------------------- 1 | import { onMessage, sendMessage } from 'webext-bridge' 2 | import type { Tabs } from 'webextension-polyfill' 3 | import browser from 'webextension-polyfill' 4 | 5 | browser.runtime.onInstalled.addListener((): void => { 6 | // eslint-disable-next-line no-console 7 | console.log('Extension installed') 8 | }) 9 | 10 | let previousTabId = 0 11 | 12 | // communication example: send previous tab title from background page 13 | // see shim.d.ts for type declaration 14 | browser.tabs.onActivated.addListener(async ({ tabId }) => { 15 | if (!previousTabId) { 16 | previousTabId = tabId 17 | return 18 | } 19 | 20 | let tab: Tabs.Tab 21 | 22 | try { 23 | tab = await browser.tabs.get(previousTabId) 24 | previousTabId = tabId 25 | } catch { 26 | return 27 | } 28 | 29 | // eslint-disable-next-line no-console 30 | console.log('previous tab', tab) 31 | sendMessage('tab-prev', { title: tab.title }, { context: 'content-script', tabId }) 32 | }) 33 | 34 | onMessage('get-current-tab', async () => { 35 | try { 36 | const tab = await browser.tabs.get(previousTabId) 37 | return { 38 | title: tab?.id, 39 | } 40 | } catch { 41 | return { 42 | title: undefined, 43 | } 44 | } 45 | }) 46 | -------------------------------------------------------------------------------- /src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const Logo = () => { 4 | return
logo
5 | } 6 | -------------------------------------------------------------------------------- /src/contentScripts/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { onMessage } from 'webext-bridge' 3 | import { App } from './views/App' 4 | import { createRoot } from 'react-dom/client' 5 | 6 | // Firefox `browser.tabs.executeScript()` requires scripts return a primitive value 7 | ;(() => { 8 | console.info('[webext-template] Hello world from content script') 9 | 10 | // communication example: send previous tab title from background page 11 | onMessage('tab-prev', ({ data }) => { 12 | console.log(`[webext-template] Navigate from page "${data.title}"`) 13 | }) 14 | 15 | // mount component to context window 16 | const container = document.createElement('div') 17 | const root = document.createElement('div') 18 | container.className = 'webext-template' 19 | const styleEl = document.createElement('link') 20 | const shadowDOM = container.attachShadow?.({ mode: __DEV__ ? 'open' : 'closed' }) || container 21 | styleEl.setAttribute('rel', 'stylesheet') 22 | styleEl.setAttribute('href', browser.runtime.getURL('dist/contentScripts/style.css')) 23 | shadowDOM.appendChild(styleEl) 24 | shadowDOM.appendChild(root) 25 | document.body.appendChild(container) 26 | const $root = createRoot(root) 27 | $root.render() 28 | })() 29 | -------------------------------------------------------------------------------- /src/contentScripts/views/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './style.css' 3 | 4 | export const App = () => { 5 | return ( 6 |
7 |
8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/contentScripts/views/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | const forbiddenProtocols = [ 2 | 'chrome-extension://', 3 | 'chrome-search://', 4 | 'chrome://', 5 | 'devtools://', 6 | 'edge://', 7 | 'https://chrome.google.com/webstore', 8 | ] 9 | 10 | export function isForbiddenUrl(url: string): boolean { 11 | return forbiddenProtocols.some((protocol) => url.startsWith(protocol)) 12 | } 13 | 14 | export const isFirefox = navigator.userAgent.includes('Firefox') 15 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const __DEV__: boolean 2 | -------------------------------------------------------------------------------- /src/hmr.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import RefreshRuntime from '/@react-refresh' 3 | RefreshRuntime.injectIntoGlobalHook(window) 4 | window.$RefreshReg$ = () => {} 5 | window.$RefreshSig$ = () => (type) => type 6 | window.__vite_plugin_react_preamble_installed__ = true 7 | -------------------------------------------------------------------------------- /src/logic/index.ts: -------------------------------------------------------------------------------- 1 | export * from './storage' 2 | -------------------------------------------------------------------------------- /src/logic/storage.ts: -------------------------------------------------------------------------------- 1 | // TODO: react use local storage hooks 2 | export const useLocalStorage = () => {} 3 | -------------------------------------------------------------------------------- /src/manifest.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import type { Manifest } from 'webextension-polyfill' 3 | import type PkgType from '../package.json' 4 | import { isDev, port, r } from '../scripts/utils' 5 | 6 | export async function getManifest() { 7 | const pkg = (await fs.readJSON(r('package.json'))) as typeof PkgType 8 | 9 | // update this file to update this manifest.json 10 | // can also be conditional based on your need 11 | const manifest: Manifest.WebExtensionManifest = { 12 | manifest_version: 3, 13 | // @ts-expect-error -- use pkg displayName if available 14 | name: pkg.displayName || pkg.name, 15 | version: pkg.version, 16 | description: pkg.description, 17 | action: { 18 | default_icon: './assets/icon-512.png', 19 | default_popup: './dist/popup/index.html', 20 | }, 21 | options_ui: { 22 | page: './dist/options/index.html', 23 | open_in_tab: true, 24 | }, 25 | background: { 26 | service_worker: './dist/background/index.mjs', 27 | }, 28 | icons: { 29 | 16: './assets/icon-512.png', 30 | 48: './assets/icon-512.png', 31 | 128: './assets/icon-512.png', 32 | }, 33 | permissions: ['tabs', 'storage', 'activeTab'], 34 | host_permissions: ['*://*/*'], 35 | content_scripts: [ 36 | { 37 | matches: ['http://*/*', 'https://*/*'], 38 | js: ['./dist/contentScripts/index.global.js'], 39 | }, 40 | ], 41 | web_accessible_resources: [ 42 | { 43 | resources: ['dist/contentScripts/style.css'], 44 | matches: [''], 45 | }, 46 | ], 47 | content_security_policy: { 48 | extension_pages: isDev 49 | // this is required on dev for Vite script to load 50 | ? `script-src 'self' http://localhost:${port}; object-src 'self' http://localhost:${port}` 51 | : 'script-src \'self\'; object-src \'self\'', 52 | }, 53 | } 54 | 55 | if (isDev) { 56 | manifest.permissions?.push('webNavigation') 57 | } 58 | 59 | return manifest 60 | } 61 | -------------------------------------------------------------------------------- /src/options/Options.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const Options = () => { 4 | return
Options
5 | } 6 | -------------------------------------------------------------------------------- /src/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Options 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/options/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | 3 | import { Options } from './Options' 4 | import './style.css' 5 | 6 | const root = createRoot(document.getElementById('root')!) 7 | 8 | root.render( 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/options/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /src/popup/Popup.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import './style.css' 3 | 4 | export function Popup() { 5 | const [count, setCount] = useState(0) 6 | 7 | return ( 8 |
9 |
10 | logo 11 |

Hello Vite + React!

12 |

13 | 16 |

17 |

18 | Edit App.tsx and save to test HMR updates. 19 |

20 |

21 | 27 | Learn React 28 | 29 | {' | '} 30 | 36 | Vite Docs 37 | 38 |

39 |
40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Popup 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/popup/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | import { Popup } from './Popup' 3 | import ready from 'document-ready' 4 | 5 | ready(() => { 6 | const root = createRoot(document.getElementById('root')!) 7 | 8 | root.render( 9 | , 10 | ) 11 | }) 12 | -------------------------------------------------------------------------------- /src/popup/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @keyframes Spin { 6 | from { 7 | transform: rotate(0deg); 8 | } 9 | to { 10 | transform: rotate(360deg); 11 | } 12 | } 13 | 14 | .container { 15 | @apply text-center; 16 | text-align: center; 17 | } 18 | 19 | .App-logo { 20 | height: 40vmin; 21 | @apply pointer-events-none; 22 | } 23 | 24 | @media (prefers-reduced-motion: no-preference) { 25 | .App-logo { 26 | animation: Spin infinite 20s linear; 27 | } 28 | } 29 | 30 | .App-header { 31 | background-color: #282c34; 32 | font-size: calc(10px + 2vmin); 33 | @apply min-h-screen flex flex-col items-center justify-center text-white; 34 | } 35 | 36 | .App-link { 37 | color: #61dafb; 38 | } 39 | 40 | button { 41 | font-size: calc(10px + 2vmin); 42 | } 43 | -------------------------------------------------------------------------------- /src/styles/index.ts: -------------------------------------------------------------------------------- 1 | import './main.css' 2 | // import 'virtual:windi.css' 3 | -------------------------------------------------------------------------------- /src/styles/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #app { 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | .btn { 9 | @apply px-4 py-1 rounded inline-block 10 | bg-teal-600 text-white cursor-pointer 11 | hover:bg-teal-700 12 | disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50; 13 | } 14 | 15 | .icon-btn { 16 | @apply inline-block cursor-pointer select-none 17 | opacity-75 transition duration-200 ease-in-out 18 | hover:opacity-100 hover:text-teal-600; 19 | font-size: 0.9em; 20 | } 21 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | // const { cssgg } = require('tailwind-cssgg') 2 | 3 | module.exports = { 4 | content: ['./src/**/*.{js,ts,jsx,tsx}'], 5 | plugins: [], 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "module": "ESNext", 5 | "target": "es2016", 6 | "jsx": "react-jsx", 7 | "lib": ["DOM", "ESNext"], 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "incremental": false, 11 | "skipLibCheck": true, 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "noUnusedLocals": true, 15 | "strictNullChecks": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "types": ["vite/client"], 18 | "paths": { 19 | "~/*": ["src/*"] 20 | } 21 | }, 22 | "exclude": ["dist", "node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | import { isDev } from './scripts/utils' 3 | 4 | export default defineConfig(() => ({ 5 | entry: { 6 | 'background/index': './src/background/main.ts', 7 | ...(isDev ? { mv3client: './scripts/client.ts' } : {}), 8 | }, 9 | outDir: 'extension/dist', 10 | // bundle all imported packages into bundle(background.ts) 11 | noExternal: [/./], 12 | format: ['esm'], 13 | target: 'esnext', 14 | ignoreWatch: ['**/extension/**'], 15 | splitting: false, 16 | sourcemap: isDev ? 'inline' : false, 17 | define: { 18 | __DEV__: JSON.stringify(isDev), 19 | 'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'), 20 | }, 21 | platform: 'browser', 22 | minifyWhitespace: !isDev, 23 | minifySyntax: !isDev, 24 | })) 25 | -------------------------------------------------------------------------------- /vite-mv-hmr.ts: -------------------------------------------------------------------------------- 1 | import { dirname, join } from 'path' 2 | import type { HMRPayload, PluginOption } from 'vite' 3 | import fs from 'fs-extra' 4 | import { isWin, r } from './scripts/utils' 5 | 6 | const targetDir = r('extension') 7 | 8 | export const MV3Hmr = (): PluginOption => { 9 | return { 10 | name: 'vite-mv3-hmr', 11 | apply: 'serve', 12 | enforce: 'post', 13 | async configureServer(server) { 14 | const originWsSend: (payload: HMRPayload) => void = server.ws.send 15 | 16 | server.ws.send = async function (payload: HMRPayload) { 17 | if (payload.type === 'update') { 18 | for (const update of payload.updates) { 19 | await writeToDisk(update.path) 20 | if (update.acceptedPath !== update.path) await writeToDisk(update.acceptedPath) 21 | } 22 | 23 | payload.updates = payload.updates.map((update) => { 24 | const isJsUpdate = update.type === 'js-update' 25 | 26 | if (!isJsUpdate) return update 27 | 28 | return { 29 | ...update, 30 | path: `${update.path}.js`, 31 | acceptedPath: `${update.acceptedPath}.js`, 32 | } 33 | }) 34 | } 35 | originWsSend.call(this, payload) 36 | } 37 | 38 | async function writeToDisk(url: string) { 39 | const result = await server.transformRequest(url.replace(/^\/@id\//, '')) 40 | let code = result?.code 41 | if (!code) return 42 | 43 | const urlModule = await server.moduleGraph.getModuleByUrl(url) 44 | const importedModules = urlModule?.importedModules 45 | 46 | if (importedModules) { 47 | for (const mod of importedModules) { 48 | code = code.replace( 49 | mod.url, 50 | normalizeViteUrl( 51 | isWin ? mod.url.replace(/[A-Z]:\//, '').replace(/:/, '.') : mod.url, 52 | mod.type, 53 | ), 54 | ) // fix invalid colon in /@fs/C:, /@id/plugin-vue:export-helper 55 | writeToDisk(mod.url) 56 | } 57 | } 58 | 59 | if (urlModule?.url) { 60 | // NOTE: code from https://github.com/antfu/vitesse-webext/tree/refactor/mv3 61 | // maybe meet some bugs with react version, open logs and rewrite request url 62 | // console.log(urlModule?.url) 63 | code = code 64 | .replace(/\/@vite\/client/g, '/dist/mv3client.mjs') 65 | .replace(/\/@id\//g, '/') 66 | .replace(/__uno.css/g, '~~uno.css') 67 | .replace(/__x00__plugin-vue:export-helper/g, '~~x00__plugin-vue:export-helper.js') 68 | .replace(/(\/\.vite\/deps\/\S+?)\?v=\w+/g, '$1') 69 | if (isWin) { 70 | code = code.replace(/(from\s+["']\/@fs\/)[A-Z]:\//g, '$1') 71 | } 72 | 73 | const targetFile = normalizeFsUrl( 74 | isWin ? urlModule.url.replace(/[A-Z]:\//, '').replace(/:/, '.') : urlModule.url, 75 | urlModule.type, 76 | ) // fix invalid colon in /@fs/C:, /@id/plugin-vue:export-helper 77 | await fs.ensureDir(dirname(targetFile)) 78 | await fs.writeFile(targetFile, code) 79 | } 80 | } 81 | 82 | Object.keys(server.config.build.rollupOptions.input!).map((entry) => 83 | writeToDisk(`/${entry}/main.ts`), 84 | ) 85 | }, 86 | } 87 | } 88 | 89 | function normalizeViteUrl(url: string, type: string) { 90 | url = url.replace(/\?v=\w+$/, '') 91 | 92 | if (type === 'js' && !url.endsWith('.js') && !url.endsWith('.mjs')) 93 | url = `${url}.js`.replace(/vue\?/, 'vue.js_') 94 | 95 | return url 96 | } 97 | 98 | function normalizeFsUrl(url: string, type: string) { 99 | return join( 100 | targetDir, 101 | normalizeViteUrl(url, type) 102 | .replace(/^\//, '') 103 | // `\0plugin-vue:export-helper` EXPORT_HELPER_ID 104 | // eslint-disable-next-line no-control-regex 105 | .replace(/\u0000/g, '__x00__') 106 | // filenames starting with "_" are reserved for use by the system. 107 | .replace(/^_+/, (match) => '~'.repeat(match.length)), 108 | ) 109 | } 110 | -------------------------------------------------------------------------------- /vite.config.content.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { sharedConfig } from './vite.config' 3 | import { isDev, r } from './scripts/utils' 4 | 5 | // bundling the content script using Vite 6 | export default defineConfig({ 7 | ...sharedConfig, 8 | build: { 9 | watch: isDev 10 | ? { 11 | include: [r('src/contentScripts/**/*'), r('src/components/**/*'), r('src/logic/**/*'), r('src/styles/**/*')], 12 | } 13 | : undefined, 14 | outDir: r('extension/dist/contentScripts'), 15 | minify: !isDev, 16 | cssCodeSplit: false, 17 | emptyOutDir: false, 18 | sourcemap: isDev ? 'inline' : false, 19 | lib: { 20 | entry: r('src/contentScripts/index.tsx'), 21 | formats: ['es'], 22 | }, 23 | rollupOptions: { 24 | output: { 25 | entryFileNames: 'index.global.js', 26 | }, 27 | }, 28 | }, 29 | plugins: [...sharedConfig.plugins!], 30 | }) 31 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import type { UserConfig } from 'vite' 3 | import AutoImport from 'unplugin-auto-import/vite' 4 | import react from '@vitejs/plugin-react' 5 | import replace from '@rollup/plugin-replace' 6 | import { MV3Hmr } from './vite-mv-hmr' 7 | import { isDev, port, r } from './scripts/utils' 8 | 9 | export const sharedConfig: UserConfig = { 10 | root: r('src'), 11 | resolve: { 12 | alias: { 13 | '~/': `${r('src')}/`, 14 | }, 15 | }, 16 | define: { 17 | __DEV__: isDev, 18 | }, 19 | plugins: [ 20 | react(), 21 | AutoImport({ 22 | imports: [ 23 | { 24 | 'webextension-polyfill': [['default', 'browser']], 25 | }, 26 | ], 27 | dts: r('src/auto-imports.d.ts'), 28 | }), 29 | 30 | // @ts-expect-error -- rollup conflict with tsup rollup 31 | replace({ 32 | preventAssignment: true, 33 | values: { 34 | __DEV__: JSON.stringify(isDev), 35 | 'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'), 36 | }, 37 | }), 38 | 39 | // rewrite assets to use relative path 40 | { 41 | name: 'assets-rewrite', 42 | enforce: 'post', 43 | apply: 'build', 44 | transformIndexHtml(html) { 45 | return html.replace(/"\/assets\//g, '"../assets/') 46 | }, 47 | }, 48 | ], 49 | optimizeDeps: { 50 | include: ['webextension-polyfill'], 51 | }, 52 | } 53 | 54 | export default defineConfig(({ command }) => { 55 | return { 56 | ...sharedConfig, 57 | base: command === 'serve' ? `http://localhost:${port}/` : undefined, 58 | server: { 59 | port, 60 | hmr: { 61 | host: 'localhost', 62 | }, 63 | }, 64 | build: { 65 | outDir: r('extension/dist'), 66 | emptyOutDir: false, 67 | sourcemap: isDev ? 'inline' : false, 68 | // https://developer.chrome.com/docs/webstore/program_policies/#:~:text=Code%20Readability%20Requirements 69 | terserOptions: { 70 | mangle: false, 71 | compress: { 72 | drop_console: !isDev, 73 | drop_debugger: !isDev, 74 | }, 75 | }, 76 | minify: 'terser', 77 | rollupOptions: { 78 | input: { 79 | options: r('src/options/index.html'), 80 | popup: r('src/popup/index.html'), 81 | }, 82 | }, 83 | }, 84 | plugins: [ 85 | ...sharedConfig.plugins!, 86 | // popup & options page hmr 87 | MV3Hmr(), 88 | ], 89 | } 90 | }) 91 | --------------------------------------------------------------------------------