├── .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 | [](https://github.com/neo-hack/webext-template) [](https://github.com/neo-hack/webext-template) [](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 |
4 |
--------------------------------------------------------------------------------
/extension/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
4 |
--------------------------------------------------------------------------------
/public/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
--------------------------------------------------------------------------------