feat: initial commit

This commit is contained in:
2024-10-04 03:11:54 +03:00
commit 0d0a9bf63b
39 changed files with 7640 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
.DS_Store
.idea/
.vite-ssg-dist
.vite-ssg-temp
*.crx
*.local
*.log
*.pem
*.xpi
*.zip
dist
dist-ssr
extension/manifest.json
node_modules
src/auto-imports.d.ts
src/components.d.ts
.eslintcache

7
.gitpod.Dockerfile vendored Normal file
View File

@@ -0,0 +1,7 @@
FROM gitpod/workspace-full-vnc
USER root
# Install dependencies
RUN apt-get update \
&& apt-get install -y firefox

23
.gitpod.yml Normal file
View File

@@ -0,0 +1,23 @@
image:
file: .gitpod.Dockerfile
tasks:
- init: pnpm install && pnpm run build
name: dev
command: |
gp sync-done ready
pnpm run dev
- name: pnpm start:chromium
command: |
gp sync-await ready
gp ports await 6080
gp preview $(gp url 6080)
sleep 5
pnpm start:chromium
openMode: split-right
ports:
- port: 5900
onOpen: ignore
- port: 6080
onOpen: ignore

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
shamefully-hoist=true
auto-install-peers=true

112
README.md Normal file
View File

@@ -0,0 +1,112 @@
# WebExtension Vite Starter (with React)
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.
<p align="center">
<sub>Popup</sub><br/>
<img width="655" src="https://user-images.githubusercontent.com/11247099/126741643-813b3773-17ff-4281-9737-f319e00feddc.png"><br/>
<sub>Options Page</sub><br/>
<img width="655" src="https://user-images.githubusercontent.com/11247099/126741653-43125b62-6578-4452-83a7-bee19be2eaa2.png"><br/>
<sub>Inject Vue App into the Content Script</sub><br/>
<img src="https://user-images.githubusercontent.com/11247099/130695439-52418cf0-e186-4085-8e19-23fe808a274e.png">
</p>
## Features
- ⚡️ **Instant HMR** - use **Vite** on dev (no more refresh!)
- ⚛️ [React](https://reactjs.org/)
- 🦾 [TypeScript](https://www.typescriptlang.org/) - type safe
- 🖥 Content Script - Use React even in content script
- 🌍 WebExtension - isomorphic extension for Chrome, Firefox, and others
- 📃 Dynamic `manifest.json` with full type support
## Pre-packed
### WebExtension Libraries
- [`webextension-polyfill`](https://github.com/mozilla/webextension-polyfill) - WebExtension browser API Polyfill with types
- [`webext-bridge`](https://github.com/antfu/webext-bridge) - effortlessly communication between contexts
### Vite Plugins
- [`unplugin-auto-import`](https://github.com/antfu/unplugin-auto-import) - Directly use `browser` without importing
### Coding Style
- [ESLint](https://eslint.org/)
- [Prettier](https://prettier.io/)
### Dev tools
- [TypeScript](https://www.typescriptlang.org/)
- [pnpm](https://pnpm.io/) - fast, disk space efficient package manager
- [esno](https://github.com/antfu/esno) - TypeScript / ESNext node runtime powered by esbuild
- [npm-run-all](https://github.com/mysticatea/npm-run-all) - Run multiple npm-scripts in parallel or sequential
- [web-ext](https://github.com/mozilla/web-ext) - Streamlined experience for developing web extensions
## Use the Template
### GitHub Template
[Create a repo from this template on GitHub](https://github.com/YangJonghun/vite-react-webext/generate).
### Clone to local
If you prefer to do it manually with the cleaner git history
> If you don't have pnpm installed, run: npm install -g pnpm
```bash
npx degit YangJonghun/vite-react-webext my-webext
cd my-webext
pnpm
```
## Usage
### Folders
- `src` - main source.
- `assets` - shareable public assets.
- `background` - scripts for background.
- `contentScripts` - scripts and components to be injected as `content_script`
- `components` - auto-imported Vue components that are shared in popup and options page.
- `styles` - styles shared in popup and options page
- `manifest.ts` - manifest for the extension (v2).
- `extension` - extension package root.
- `assets` - static assets.
- `dist` - built files, also serve stub entry for Vite on development.
- `scripts` - development and bundling helper scripts.
### Development
```bash
pnpm dev
```
Then **load extension in browser with the `extension/` folder**.
For Firefox developers, you can run the following command instead:
```bash
pnpm start:firefox
```
`web-ext` auto reload the extension when `extension/` files changed.
> 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.
### Build
To build the extension, run
```bash
pnpm build
```
And then pack files under `extension`, you can upload `extension.crx` or `extension.xpi` to appropriate extension store.
## Credits
This template codes are based on
[Anthony Fu](https://github.com/antfu)'s [vitesse-webext](https://github.com/antfu/vitesse-webext).

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M26.6667 1.66667H24V7H8V9.66667H5.33333V20.3333H8V23H10.6667V28.3333H21.3333V25.6667H26.6667V23H21.3333V20.3333H26.6667V17.6667H21.3333V15H10.6667V20.3333H8V9.66667H24V7H26.6667V1.66667ZM18.6667 25.6667H13.3333V17.6667H18.6667V25.6667Z" fill="#888888"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

63
package.json Normal file
View File

@@ -0,0 +1,63 @@
{
"name": "vite-react-webext",
"displayName": "Copy Tab URL",
"version": "0.0.1",
"type": "module",
"description": "Easy shortcuts for copying tab URL with or without title, with customizable format.",
"packageManager": "pnpm@9.9.0",
"private": true,
"scripts": {
"dev": "npm run clear && cross-env NODE_ENV=development run-p 'dev:*'",
"dev:prepare": "esno scripts/prepare.ts",
"dev:web": "vite",
"dev:js": "npm run build:js -- --mode development",
"build": "cross-env NODE_ENV=production run-s clear build:web build:prepare build:js",
"build:prepare": "esno scripts/prepare.ts",
"build:web": "vite build",
"build:js": "vite build --config vite.config.content.ts",
"preview": "vite preview",
"pack": "cross-env NODE_ENV=production run-p 'pack:*'",
"pack:zip": "rimraf extension.zip && jszip-cli add extension/* -o ./extension.zip",
"pack:crx": "crx pack extension -o ./extension.crx",
"pack:xpi": "cross-env WEB_EXT_ARTIFACTS_DIR=./ web-ext build --source-dir ./extension --filename extension.xpi --overwrite-dest",
"start:chromium": "web-ext run --source-dir ./extension --target=chromium",
"start:firefox": "web-ext run --source-dir ./extension --target=firefox-desktop",
"clear": "rimraf extension/dist extension/manifest.json 'extension.*'",
"lint": "eslint --ignore-path .gitignore './**/*.{ts,tsx,js,jsx}'"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/node": "^22.7.4",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/webextension-polyfill": "^0.12.1",
"@typescript-eslint/eslint-plugin": "^8.8.0",
"@typescript-eslint/parser": "^8.8.0",
"@vitejs/plugin-react": "^4.3.2",
"chokidar": "^4.0.1",
"cross-env": "^7.0.3",
"crx": "^5.0.1",
"eslint": "^9.11.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.1",
"esno": "^4.8.0",
"fs-extra": "^11.2.0",
"kolorist": "^1.8.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.3.3",
"rimraf": "^6.0.1",
"typescript": "^5.6.2",
"unplugin-auto-import": "^0.18.3",
"vite": "^5.4.8",
"web-ext": "^8.3.0",
"webext-bridge": "^6.0.1",
"webextension-polyfill": "^0.12.0"
}
}

6612
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

12
scripts/manifest.ts Normal file
View File

@@ -0,0 +1,12 @@
import { getManifest } from '../src/manifest'
import { r, log } from './utils'
import fs from 'fs-extra'
export async function writeManifest() {
await fs.writeJSON(r('extension/manifest.json'), await getManifest(), {
spaces: 2,
})
log('PRE', 'write manifest.json')
}
writeManifest()

55
scripts/prepare.ts Normal file
View File

@@ -0,0 +1,55 @@
// generate stub index.html files for dev entry
import { execSync } from 'child_process'
import { r, port, isDev, log, fastRefresh, preambleCode } from './utils'
import fs from 'fs-extra'
import chokidar from 'chokidar'
/**
* Stub index.html to use Vite in development
*/
export async function stubIndexHtml() {
const views = ['options', 'popup', 'background']
const preambleCodeScriptTag = fastRefresh
? `<script type="module">${preambleCode}</script>`
: ''
for (const view of views) {
await fs.ensureDir(r(`extension/dist/${view}`))
let data = await fs.readFile(r(`src/${view}/index.html`), 'utf-8')
data = data
.replace(
/<!-- react-hmr -->/g,
`<base href="http://localhost:${port}" />
<script type="module" src="/@vite/client"></script>
${preambleCodeScriptTag}`,
)
.replace(
/".\/main.((js|jsx|ts|tsx)?)"/g,
(_, ext) => `"./${view}/main.${ext}"`,
)
.replace(
'<div id="root"></div>',
'<div id="root">Vite server did not start</div>',
)
await fs.writeFile(r(`extension/dist/${view}/index.html`), data, 'utf-8')
log('PRE', `stub ${view}`)
}
}
export function writeManifest() {
execSync('npx esno ./scripts/manifest.ts', { stdio: 'inherit' })
}
writeManifest()
if (isDev) {
stubIndexHtml()
chokidar.watch(r('src/**/*.html')).on('change', () => {
stubIndexHtml()
})
chokidar.watch([r('src/manifest.ts'), r('package.json')]).on('change', () => {
writeManifest()
})
}

16
scripts/utils.ts Normal file
View File

@@ -0,0 +1,16 @@
import { resolve } from 'path'
import { bgCyan, black } from 'kolorist'
import react from '@vitejs/plugin-react'
const __dirname = import.meta.dirname
export const fastRefresh = true
export const port = parseInt(process.env.PORT || '') || 3303
export const r = (...args: string[]) => resolve(__dirname, '..', ...args)
export const isDev = process.env.NODE_ENV !== 'production'
export const preambleCode = react.preambleCode.replace('__BASE__', '/')
export function log(name: string, message: string) {
// eslint-disable-next-line no-console
console.log(black(bgCyan(` ${name} `)), message)
}

10
shim.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
import type { ProtocolWithReturn } from 'webext-bridge'
declare module 'webext-bridge' {
export interface ProtocolMap {
// define message protocol types
// see https://github.com/antfu/webext-bridge#type-safe-protocols
'tab-prev': { title: string | undefined }
'get-current-tab': ProtocolWithReturn<{ tabId: number }, { title?: string }>
}
}

43
src/App.tsx Normal file
View File

@@ -0,0 +1,43 @@
import logo from './logo.svg'
import { useState } from 'react'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>Hello Vite + React!</p>
<p>
<button type="button" onClick={() => setCount(count => count + 1)}>
count is: {count} {''}
</button>
</p>
<p>
Edit <code>App.tsx</code> and save to test HMR updates.
</p>
<p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer">
Learn React
</a>
{' | '}
<a
className="App-link"
href="https://vitejs.dev/guide/features.html"
target="_blank"
rel="noopener noreferrer">
Vite Docs
</a>
</p>
</header>
</div>
)
}
export default App

15
src/assets/favicon.svg Normal file
View File

@@ -0,0 +1,15 @@
<svg width="410" height="404" viewBox="0 0 410 404" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.378 198.228 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8058C397.439 43.2894 403.768 52.1434 399.641 59.5246Z" fill="url(#paint0_linear)"/>
<path d="M292.965 1.5744L156.801 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8664L144.395 174.33C144.198 177.662 147.258 180.248 150.51 179.498L188.42 170.749C191.967 169.931 195.172 173.055 194.443 176.622L183.18 231.775C182.422 235.487 185.907 238.661 189.532 237.56L212.947 230.446C216.577 229.344 220.065 232.527 219.297 236.242L201.398 322.875C200.278 328.294 207.486 331.249 210.492 326.603L212.5 323.5L323.454 102.072C325.312 98.3645 322.108 94.137 318.036 94.9228L279.014 102.454C275.347 103.161 272.227 99.746 273.262 96.1583L298.731 7.86689C299.767 4.27314 296.636 0.855181 292.965 1.5744Z" fill="url(#paint1_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="6.00017" y1="32.9999" x2="235" y2="344" gradientUnits="userSpaceOnUse">
<stop stop-color="#41D1FF"/>
<stop offset="1" stop-color="#BD34FE"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="194.651" y1="8.81818" x2="236.076" y2="292.989" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEA83"/>
<stop offset="0.0833333" stop-color="#FFDD35"/>
<stop offset="1" stop-color="#FFA800"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

7
src/assets/logo.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,18 @@
import { isFirefox, isForbiddenUrl } from '@/env'
// Firefox fetch files from cache instead of reloading changes from disk,
// hmr will not work as Chromium based browser
browser.webNavigation.onCommitted.addListener(({ tabId, frameId, url }) => {
// Filter out non main window events.
if (frameId !== 0) return
if (isForbiddenUrl(url)) return
// inject the latest scripts
browser.tabs
.executeScript(tabId, {
file: `${isFirefox ? '' : '.'}/dist/contentScripts/index.global.js`,
runAt: 'document_end',
})
.catch(error => console.error(error))
})

11
src/background/index.html Normal file
View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- react-hmr -->
<meta charset="UTF-8" />
<title>Background</title>
</head>
<body>
<script type="module" src="./main.ts"></script>
</body>
</html>

56
src/background/main.ts Normal file
View File

@@ -0,0 +1,56 @@
import { sendMessage, onMessage } from 'webext-bridge'
import type { Tabs } from 'webextension-polyfill'
// only on dev mode
if (import.meta.hot) {
// @ts-expect-error for background HMR
import('/@vite/client')
// load latest content script
import('./contentScriptHMR')
}
browser.runtime.onInstalled.addListener((): void => {
// eslint-disable-next-line no-console
console.log('Extension installed')
})
let previousTabId = 0
// communication example: send previous tab title from background page
// see shim.d.ts for type declaration
browser.tabs.onActivated.addListener(async ({ tabId }) => {
if (!previousTabId) {
previousTabId = tabId
return
}
let tab: Tabs.Tab
try {
tab = await browser.tabs.get(previousTabId)
previousTabId = tabId
} catch {
return
}
// eslint-disable-next-line no-console
console.log('previous tab', tab)
sendMessage(
'tab-prev',
{ title: tab.title },
{ context: 'content-script', tabId },
)
})
onMessage('get-current-tab', async () => {
try {
const tab = await browser.tabs.get(previousTabId)
return {
title: tab?.title,
}
} catch {
return {
title: undefined,
}
}
})

View File

@@ -0,0 +1,10 @@
const App = () => {
return (
<div>
<div>Vitesse WebExt</div>
<div></div>
</div>
)
}
export default App

View File

@@ -0,0 +1,30 @@
import App from './App'
import * as ReactDOM from 'react-dom/client'
import { onMessage } from 'webext-bridge/content-script'
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
;(() => {
console.info('[vite-react-webext] Hello world from content script')
// communication example: send previous tab title from background page
onMessage('tab-prev', ({ data }) => {
console.log(`[vite-react-webext] Navigate from page "${data.title}"`)
})
// mount component to context window
const container = document.createElement('div')
const root = document.createElement('div')
const styleEl = document.createElement('link')
const shadowDOM =
container.attachShadow?.({ mode: __DEV__ ? 'open' : 'closed' }) || container
styleEl.setAttribute('rel', 'stylesheet')
styleEl.setAttribute(
'href',
browser.runtime.getURL('dist/contentScripts/style.css'),
)
shadowDOM.appendChild(styleEl)
shadowDOM.appendChild(root)
document.body.appendChild(container)
const reactRoot = ReactDOM.createRoot(root)
reactRoot.render(<App />)
})()

14
src/env.ts Normal file
View File

@@ -0,0 +1,14 @@
const forbiddenProtocols = [
'chrome-extension://',
'chrome-search://',
'chrome://',
'devtools://',
'edge://',
'https://chrome.google.com/webstore',
]
export function isForbiddenUrl(url: string): boolean {
return forbiddenProtocols.some(protocol => url.startsWith(protocol))
}
export const isFirefox = navigator.userAgent.includes('Firefox')

1
src/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare const __DEV__: boolean

View File

@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react'
import type { Storage } from 'webextension-polyfill'
import { storage } from 'webextension-polyfill'
interface Params<T> {
key: string
initialValue?: T | null
}
export function useStorageLocal<T>({ key, initialValue = null }: Params<T>) {
const [value, _setValue] = useState<T | null>(initialValue)
useEffect(() => {
storage.local.get(key).then(val => _setValue(val[key] ?? initialValue))
const callback = (changes: Record<string, Storage.StorageChange>) => {
if (changes[key]) _setValue(changes[key].newValue)
}
storage.onChanged.addListener(callback)
return () => {
storage.onChanged.removeListener(callback)
}
}, [key, initialValue])
const setValue = async (val: T | null) => {
_setValue(val)
await storage.local.set({ [key]: val })
}
return [value, setValue] as [T | null, (val: T) => void]
}

62
src/manifest.ts Normal file
View File

@@ -0,0 +1,62 @@
import crypto from 'crypto'
import type PkgType from '../package.json'
import { isDev, port, r, preambleCode } from '../scripts/utils'
import fs from 'fs-extra'
import type { Manifest } from 'webextension-polyfill'
export async function getManifest() {
const pkg = (await fs.readJSON(r('package.json'))) as typeof PkgType
// update this file to update this manifest.json
// can also be conditional based on your need
const manifest: Manifest.WebExtensionManifest = {
manifest_version: 2,
name: pkg.displayName || pkg.name,
version: pkg.version,
description: pkg.description,
browser_action: {
default_icon: './assets/icon-512.png',
default_popup: './dist/popup/index.html',
},
options_ui: {
page: './dist/options/index.html',
open_in_tab: true,
chrome_style: false,
},
background: {
page: './dist/background/index.html',
persistent: false,
},
icons: {
16: './assets/icon-512.png',
48: './assets/icon-512.png',
128: './assets/icon-512.png',
},
permissions: ['tabs', 'storage', 'activeTab', 'http://*/', 'https://*/'],
content_scripts: [
{
matches: ['http://*/*', 'https://*/*'],
js: ['./dist/contentScripts/index.global.js'],
},
],
web_accessible_resources: ['dist/contentScripts/style.css'],
}
if (isDev) {
// for content script, as browsers will cache them for each reload,
// we use a background script to always inject the latest version
// see src/background/contentScriptHMR.ts
delete manifest.content_scripts
manifest.permissions?.push('webNavigation')
const preambleCodeHash = crypto
.createHash('sha256')
.update(preambleCode)
.digest('base64')
// this is required on dev for Vite script to load
manifest.content_security_policy = `script-src \'self\' 'sha256-${preambleCodeHash}' http://localhost:${port}; object-src \'self\'`
}
return manifest
}

47
src/options/Options.css Normal file
View File

@@ -0,0 +1,47 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
input {
margin: 0;
line-height: 1.15;
transition: border-color calc(var(--transition, 0.2) * 1s) ease;
outline: transparent;
text-align: center;
font-size: calc(10px + 2vmin);
}

32
src/options/Options.tsx Normal file
View File

@@ -0,0 +1,32 @@
import logo from '@/assets/logo.svg'
import { useStorageLocal } from '@/hooks/useStorageLocal'
import './Options.css'
const Options = () => {
const [value, setValue] = useStorageLocal<string>({ key: 'webext-demo' })
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>This is the Options page</p>
<p>
<b>Change Storage Value and Check Popup</b>
</p>
<input value={value ?? ''} onChange={e => setValue(e.target.value)} />
<p>
Powered by{' '}
<a
className="App-link"
href="https://vitejs.dev/guide/features.html"
target="_blank"
rel="noopener noreferrer">
Vite
</a>
</p>
</header>
</div>
)
}
export default Options

13
src/options/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- react-hmr -->
<meta charset="UTF-8" />
<base target="_blank" />
<title>Options</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

9
src/options/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import Options from './Options'
import * as ReactDOM from 'react-dom/client'
import '../styles'
const container = document.getElementById('root')
if (container) {
const root = ReactDOM.createRoot(container)
root.render(<Options />)
}

43
src/popup/Popup.css Normal file
View File

@@ -0,0 +1,43 @@
.App {
width: 300px;
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
button {
font-size: calc(10px + 2vmin);
}

62
src/popup/Popup.tsx Normal file
View File

@@ -0,0 +1,62 @@
import logo from '@/assets/logo.svg'
import { useStorageLocal } from '@/hooks/useStorageLocal'
import { useState } from 'react'
import './Popup.css'
const Popup = () => {
const [count, setCount] = useState(0)
const [val] = useStorageLocal<string>({ key: 'webext-demo' })
const openOptionsPage = () => {
browser.runtime.openOptionsPage()
}
return (
<div className="App">
<header className="App-header">
<img
src={logo}
className="App-logo"
alt="logo"
onClick={openOptionsPage}
/>
<p>Hello Vite + React!</p>
<p>
<button type="button" onClick={() => setCount(count => count + 1)}>
count is: {count}
</button>
</p>
<p>
Edit <code>App.tsx</code> and save to test HMR updates.
</p>
<p>
Storage: <b>{val ?? 'not exist'}</b>
</p>
<p>
<button type="button" onClick={openOptionsPage}>
open option!
</button>
</p>
<p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer">
Learn React
</a>
{' | '}
<a
className="App-link"
href="https://vitejs.dev/guide/features.html"
target="_blank"
rel="noopener noreferrer">
Vite Docs
</a>
</p>
</header>
</div>
)
}
export default Popup

13
src/popup/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- react-hmr -->
<meta charset="UTF-8" />
<base target="_blank" />
<title>Popup</title>
</head>
<body style="min-width: 100px">
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

9
src/popup/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import Popup from './Popup'
import * as ReactDOM from 'react-dom/client'
import '../styles'
const container = document.getElementById('root')
if (container) {
const root = ReactDOM.createRoot(container)
root.render(<Popup />)
}

1
src/styles/index.ts Normal file
View File

@@ -0,0 +1 @@
import './main.css'

13
src/styles/main.css Normal file
View File

@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

36
tsconfig.json Normal file
View File

@@ -0,0 +1,36 @@
{
"compilerOptions": {
"baseUrl": ".",
"module": "ESNext",
"target": "es2016",
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"strict": true,
"esModuleInterop": false,
"skipLibCheck": true,
"moduleResolution": "Node",
"useDefineForClassFields": true,
"allowJs": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": [
"vite/client"
],
"paths": {
"@/*": [
"src/*"
]
}
},
"exclude": [
"dist",
"node_modules"
]
}

27
vite.config.content.ts Normal file
View File

@@ -0,0 +1,27 @@
import { sharedConfig } from './vite.config'
import { r, isDev } from './scripts/utils'
import packageJson from './package.json'
import { defineConfig } from 'vite'
// bundling the content script using Vite
export default defineConfig({
...sharedConfig,
build: {
watch: isDev ? {} : undefined,
outDir: r('extension/dist/contentScripts'),
cssCodeSplit: false,
emptyOutDir: false,
sourcemap: isDev ? 'inline' : false,
lib: {
entry: r('src/contentScripts/main.tsx'),
name: packageJson.name,
formats: ['iife'],
},
rollupOptions: {
output: {
entryFileNames: 'index.global.js',
extend: true,
},
},
},
})

93
vite.config.ts Normal file
View File

@@ -0,0 +1,93 @@
import { dirname, relative } from 'path'
import { readFile } from 'fs'
import autoImport from 'unplugin-auto-import/vite'
import { r, port, isDev, fastRefresh } from './scripts/utils'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import type { UserConfig } from 'vite'
export const sharedConfig: UserConfig = {
root: r('src'),
resolve: {
alias: {
'@/': `${r('src')}/`,
},
},
define: {
__DEV__: isDev,
},
plugins: [
react({ fastRefresh }),
autoImport({
include: [/\.[tj]sx?$/],
imports: [
{
'webextension-polyfill': [['*', 'browser']],
},
],
// Filepath to generate corresponding .d.ts file.
// Defaults to './src/auto-imports.d.ts' when `typescript` is installed locally.
// Set `false` to disable.
dts: r('src/auto-imports.d.ts'),
}),
// rewrite assets to use relative path
{
name: 'assets-rewrite',
enforce: 'post',
apply: 'build',
transformIndexHtml(html, { path }) {
return html.replace(
/"\/assets\//g,
`"${relative(dirname(path), '/assets')}/`,
)
},
},
],
optimizeDeps: {
esbuildOptions: {
plugins: [
{
name: 'load-js-files-as-jsx',
setup(build) {
build.onLoad({ filter: /src\\\.*\.js/ }, async args => ({
loader: 'jsx',
contents: void (await readFile(
args.path,
{ encoding: 'utf8' },
() => {},
)),
}))
},
},
],
},
include: ['react', 'react-dom', 'webextension-polyfill'],
},
}
export default defineConfig(({ command }) => ({
...sharedConfig,
base: command === 'serve' ? `http://localhost:${port}/` : '/dist/',
server: {
port,
hmr: {
host: 'localhost',
},
},
build: {
outDir: r('extension/dist'),
emptyOutDir: false,
sourcemap: isDev ? 'inline' : false,
// https://developer.chrome.com/docs/webstore/program_policies/#:~:text=Code%20Readability%20Requirements
terserOptions: {
mangle: false,
},
rollupOptions: {
input: {
background: r('src/background/index.html'),
options: r('src/options/index.html'),
popup: r('src/popup/index.html'),
},
},
},
}))