chore(all): init config

This commit is contained in:
molvqingtai 2023-07-12 04:22:57 +08:00
commit a719363e82
49 changed files with 9116 additions and 0 deletions

5
.commitlintrc Normal file
View file

@ -0,0 +1,5 @@
{
"extends": [
"@commitlint/config-conventional"
]
}

4
.eslintignore Normal file
View file

@ -0,0 +1,4 @@
dist
node_modules
public
extension

43
.eslintrc Normal file
View file

@ -0,0 +1,43 @@
{
"root": true,
"env": {
"es2021": true,
"browser": true,
"node": true
},
"extends": [
"standard-with-typescript",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "react", "react-hooks", "prettier"],
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module",
"project": "./tsconfig.eslint.json",
"warnOnUnsupportedTypeScriptVersion": false
},
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
"prettier/prettier": "error",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-confusing-void-expression": "off",
"@typescript-eslint/strict-boolean-expressions": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/no-misused-promises": "off"
}
}

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

4
.husky/commit-msg Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no-install commitlint --edit "$1"

4
.husky/pre-commit Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged

2
.npmrc Normal file
View file

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

6
.postcssrc Normal file
View file

@ -0,0 +1,6 @@
{
"plugins": {
"tailwindcss": {},
"autoprefixer": {}
}
}

6
.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 120
}

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Anthony Fu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

10
README.md Normal file
View file

@ -0,0 +1,10 @@
# [WIP]WebChat
> Chatting Anonymously with People on the Same Website.
## Thanks
- [vitesse-webext](https://github.com/antfu/vitesse-webext) : 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.

16
components.json Normal file
View file

@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "@/styles/main.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/utils/index.ts"
}
}

15
e2e/basic.spec.ts Normal file
View file

@ -0,0 +1,15 @@
import { expect, isDevArtifact, name, test } from './fixtures'
test('example test', async ({ page }, testInfo) => {
testInfo.skip(!isDevArtifact(), 'contentScript is in closed ShadowRoot mode')
await page.goto('https://example.com')
await page.locator(`#${name} button`).click()
await expect(page.locator(`#${name} h1`)).toHaveText('Vitesse WebExt')
})
test('options page', async ({ page, extensionId }) => {
await page.goto(`chrome-extension://${extensionId}/dist/options/index.html`)
await expect(page.locator('img')).toHaveAttribute('alt', 'extension icon')
})

47
e2e/fixtures.ts Normal file
View file

@ -0,0 +1,47 @@
import path from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
import fs from 'fs-extra'
import { type BrowserContext, test as base, chromium } from '@playwright/test'
import type { Manifest } from 'webextension-polyfill'
export { name } from '../package.json'
export const extensionPath = path.join(__dirname, '../extension')
export const test = base.extend<{
context: BrowserContext
extensionId: string
}>({
context: async ({ headless }, use) => {
// workaround for the Vite server has started but contentScript is not yet.
await sleep(1000)
const context = await chromium.launchPersistentContext('', {
headless,
args: [
...(headless ? ['--headless=new'] : []),
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`
]
})
await use(context)
await context.close()
},
extensionId: async ({ context }, use) => {
// for manifest v3:
let [background] = context.serviceWorkers()
if (!background) background = await context.waitForEvent('serviceworker')
const extensionId = background.url().split('/')[2]
await use(extensionId)
}
})
export const expect = test.expect
export function isDevArtifact() {
const manifest: Manifest.WebExtensionManifest = fs.readJsonSync(path.resolve(extensionPath, 'manifest.json'))
return Boolean(
typeof manifest.content_security_policy === 'object' &&
manifest.content_security_policy.extension_pages?.includes('localhost')
)
}

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

104
package.json Normal file
View file

@ -0,0 +1,104 @@
{
"name": "web-chat",
"displayName": "WebChat",
"version": "0.0.1",
"description": "Chatting Anonymously with People on the Same Website.",
"scripts": {
"dev": "npm run clear && cross-env NODE_ENV=development run-p dev:*",
"dev-firefox": "npm run clear && cross-env NODE_ENV=development EXTENSION=firefox run-p dev:*",
"dev:prepare": "esno scripts/prepare.ts",
"dev:background": "npm run build:background -- --mode development",
"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:background build:js",
"build:prepare": "esno scripts/prepare.ts",
"build:background": "vite build --config vite.config.background.ts",
"build:web": "vite build",
"build:js": "vite build --config vite.config.content.ts",
"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 --glob extension/dist extension/manifest.json extension.*",
"lint": "npx eslint . --ext .js,.jsx,.ts,.tsx --cache --fix",
"test": "vitest test",
"test:e2e": "playwright test",
"tsc:check": "tsc --noEmit",
"prepare": "husky install"
},
"repository": {
"type": "git",
"url": "git+https://github.com/molvqingtai/WebChat.git"
},
"keywords": [
"Chat",
"Browser"
],
"author": "molvqingtai",
"license": "MIT",
"bugs": {
"url": "https://github.com/molvqingtai/WebChat/issues"
},
"homepage": "https://github.com/molvqingtai/WebChat#readme",
"devDependencies": {
"@commitlint/cli": "^17.6.6",
"@commitlint/config-conventional": "^17.6.6",
"@ffflorian/jszip-cli": "^3.4.1",
"@iconify/json": "^2.2.89",
"@playwright/test": "^1.36.0",
"@svgr/core": "^8.0.0",
"@svgr/plugin-jsx": "^8.0.1",
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.4.1",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@types/webextension-polyfill": "^0.10.1",
"@vitejs/plugin-react": "^4.0.3",
"autoprefixer": "^10.4.14",
"chokidar": "^3.5.3",
"cross-env": "^7.0.3",
"crx": "^5.0.1",
"eslint": "^8.44.0",
"eslint-config-prettier": "^8.8.0",
"eslint-config-standard-with-typescript": "^36.0.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-n": "^16.0.1",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"esno": "^0.16.3",
"fs-extra": "^11.1.1",
"husky": "^8.0.3",
"jsdom": "^22.1.0",
"kolorist": "^1.8.0",
"lint-staged": "^13.2.3",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.25",
"prettier": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"rimraf": "^5.0.1",
"tailwindcss": "^3.3.2",
"typescript": "^5.1.6",
"unplugin-auto-import": "^0.16.6",
"unplugin-icons": "^0.16.3",
"vite": "^4.4.3",
"vitest": "^0.33.0",
"web-ext": "^7.6.2",
"webext-bridge": "^6.0.1",
"webextension-polyfill": "^0.10.0"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": "eslint --fix"
},
"dependencies": {
"@radix-ui/react-icons": "^1.3.0",
"class-variance-authority": "^0.6.1",
"clsx": "^1.2.1",
"tailwind-merge": "^1.13.2",
"tailwindcss-animate": "^1.0.6"
}
}

15
playwright.config.ts Normal file
View file

@ -0,0 +1,15 @@
/**
* @see {@link https://playwright.dev/docs/chrome-extensions Chrome extensions | Playwright}
*/
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
retries: 2,
webServer: {
command: 'npm run dev',
// start e2e test after the Vite server is fully prepared
url: 'http://localhost:3303/popup/main.ts',
reuseExistingServer: true
}
})

8026
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

10
scripts/manifest.ts Normal file
View file

@ -0,0 +1,10 @@
import fs from 'fs-extra'
import { getManifest } from '../src/manifest'
import { log, r } from './utils'
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 'node:child_process'
import path from 'node:path'
import fs from 'fs-extra'
import chokidar from 'chokidar'
import { isDev, log, port, r } from './utils'
/**
* Stub index.html to use Vite in development
*/
async function stubIndexHtml() {
const views = ['options', 'background', 'sidebar']
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('</head>', '<script type="module" src="/dist/refreshPreamble.js"></script></head>')
.replace('"./main.tsx"', `"http://localhost:${port}/${view}/main.tsx"`)
.replace('<div id="app"></div>', '<div id="app">Vite server did not start</div>')
await fs.writeFile(r(`extension/dist/${view}/index.html`), data, 'utf-8')
log('PRE', `stub ${view}`)
}
}
// This enables hot module reloading
async function writeRefreshPreamble() {
const data = `
import RefreshRuntime from "http://localhost:${port}/@react-refresh";
RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
`
await fs.ensureDir(r('extension/dist'))
await fs.writeFile(path.join(r('extension/dist/'), 'refreshPreamble.js'), data, 'utf-8')
}
function writeManifest() {
execSync('npx esno ./scripts/manifest.ts', { stdio: 'inherit' })
}
writeManifest()
if (isDev) {
writeRefreshPreamble()
stubIndexHtml()
chokidar.watch(r('src/**/*.html')).on('change', () => {
stubIndexHtml()
})
chokidar.watch([r('src/manifest.ts'), r('package.json')]).on('change', () => {
writeManifest()
})
}

11
scripts/utils.ts Normal file
View file

@ -0,0 +1,11 @@
import { resolve } from 'node:path'
import { bgCyan, black } from 'kolorist'
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 isFirefox = process.env.EXTENSION === 'firefox'
export function log(name: string, message: string) {
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 }>
}
}

3
src/assets/icon.svg Normal file
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="#69717d"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

View file

@ -0,0 +1,19 @@
import browser from 'webextension-polyfill'
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))
})

12
src/background/index.html Normal file
View file

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

51
src/background/main.ts Normal file
View file

@ -0,0 +1,51 @@
import { onMessage, sendMessage } from 'webext-bridge/background'
import type { Tabs } from 'webextension-polyfill'
import browser from 'webextension-polyfill'
// only on dev mode
if (import.meta.hot != null) {
// @ts-expect-error for background HMR
import('@vite/client')
// load latest content script
import('./contentScriptHMR')
}
browser.runtime.onInstalled.addListener((): void => {
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
}
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
}
}
})

15
src/components/Logo.tsx Normal file
View file

@ -0,0 +1,15 @@
import IconPower from '~icons/pixelarticons/power'
export default function Logo() {
return (
<a
className="icon-btn mx-2 text-2xl"
rel="noreferrer"
href="https://github.com/antfu/vitesse-webext"
target="_blank"
title="GitHub"
>
<IconPower />
</a>
)
}

View file

@ -0,0 +1,33 @@
/* eslint-disable no-console */
import { createRoot } from 'react-dom/client'
import { onMessage } from 'webext-bridge/content-script'
import browser from 'webextension-polyfill'
import { renderApp } from './render'
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
;(() => {
console.info(`[__NAME__] Hello world from content script`)
// communication example: send previous tab title from background page
onMessage('tab-prev', ({ data }) => {
console.log(`[__NAME__] Navigate from page "${data.title}"`)
})
// mount component to context window
const container = document.createElement('div')
container.id = __NAME__
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 appRoot = createRoot(root)
renderApp({
root: appRoot,
frameUrl: browser.runtime.getURL('dist/sidebar/index.html')
})
})()

View file

@ -0,0 +1,34 @@
import { useState } from 'react'
import IconPower from '~icons/pixelarticons/power'
import '@/styles/main.css'
export default function App({ frameUrl }: { frameUrl: string }) {
const [open, setOpen] = useState(false)
const [openedOnce, setOpenedOnce] = useState(false)
return (
<div>
<div className="fixed left-0 bottom-0 m-5 z-100 flex font-sans select-none leading-1em">
<div
className="flex justify-center items-center w-10 h-10 rounded-full shadow cursor-pointer bg-blue-400 hover:bg-blue-600"
onClick={() => {
setOpen((open) => !open)
setOpenedOnce(true)
}}
>
<IconPower />
</div>
</div>
{openedOnce && (
<div
className={`fixed top-0 right-0 h-full w-1/4 z-50 bg-white drop-shadow-xl transition-transform ${
open ? 'translate-x-0' : 'translate-x-full'
}`}
>
<iframe src={frameUrl} className="w-full h-full border-0" />
</div>
)}
</div>
)
}

View file

@ -0,0 +1,13 @@
// use a separate file from index.ts to keep the diff as simple as possible
import React from 'react'
import type { Root } from 'react-dom/client'
import App from './pages/App'
export const renderApp = ({ root, frameUrl }: { root: Root; frameUrl: string }) => {
return root.render(
<React.StrictMode>
<App frameUrl={frameUrl} />
</React.StrictMode>
)
}

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')

3
src/global.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
declare const __DEV__: boolean
/** Extension name, defined in packageJson.name */
declare const __NAME__: string

29
src/hooks/useStorage.ts Normal file
View file

@ -0,0 +1,29 @@
import { useEffect, useState } from 'react'
import type { Storage } from 'webextension-polyfill'
import { storage } from 'webextension-polyfill'
export function useStorage(key: string, initialValue: string) {
const [value, setValue] = useState(async () => {
const storedValue = await storage.local.get(key)
return storedValue !== null ? storedValue : initialValue
})
useEffect(() => {
const onChange = (changes: Record<string, Storage.StorageChange>) => {
if (changes[key]) setValue(changes[key].newValue)
}
storage.onChanged.addListener(onChange)
;(async () => {
const value = await storage.local.get(key)
setValue(value[key])
})()
return () => storage.onChanged.removeListener(onChange)
}, [])
const setValueToStorage = async (newValue: string) => {
setValue(newValue)
await storage.local.set({ [key]: newValue })
}
return [value, setValueToStorage]
}

59
src/manifest.ts Normal file
View file

@ -0,0 +1,59 @@
import fs from 'fs-extra'
import type { Manifest } from 'webextension-polyfill'
import type PkgType from '../package.json'
import { isDev, isFirefox, port, r } from '../scripts/utils'
export async function getManifest(): Promise<Manifest.WebExtensionManifest> {
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: 3,
name: pkg.displayName || pkg.name,
version: pkg.version,
description: pkg.description,
action: {
default_icon: './assets/icon-512.png'
},
options_ui: {
page: './dist/options/index.html',
open_in_tab: true
},
background: isFirefox
? {
scripts: ['dist/background/index.mjs'],
type: 'module'
}
: {
service_worker: './dist/background/index.mjs'
},
icons: {
16: './assets/icon-512.png',
48: './assets/icon-512.png',
128: './assets/icon-512.png'
},
permissions: ['tabs', 'storage', 'activeTab'],
host_permissions: ['*://*/*'],
content_scripts: [
{
matches: ['<all_urls>'],
js: ['dist/contentScripts/index.global.js']
}
],
web_accessible_resources: [
{
resources: ['dist/contentScripts/style.css', 'dist/sidebar/index.html'],
matches: ['<all_urls>']
}
],
content_security_policy: {
extension_pages: isDev
? // this is required on dev for Vite script to load
`script-src 'self' http://localhost:${port}; object-src 'self'`
: "script-src 'self'; object-src 'self'"
}
}
return manifest
}

16
src/options/Options.tsx Normal file
View file

@ -0,0 +1,16 @@
import IconSliders from '~icons/pixelarticons/sliders'
import IconZap from '~icons/pixelarticons/zap'
export default function Options() {
return (
<main className="px-4 py-10 text-center text-gray-700 dark:text-gray-200">
<IconSliders className="icon-btn mx-2 text-2xl" />
<div>Options</div>
<p className="mt-2 opacity-50">This is the options page</p>
<div className="mt-4 flex justify-center">
Powered by Vite <IconZap className="align-middle" />
</div>
</main>
)
}

12
src/options/index.html Normal file
View file

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

13
src/options/main.tsx Normal file
View file

@ -0,0 +1,13 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import '@/styles/main.css'
import App from './Options'
const container = document.getElementById('app')
const root = createRoot(container!)
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
)

11
src/sidebar/Sidebar.tsx Normal file
View file

@ -0,0 +1,11 @@
import Logo from '@/components/Logo'
export default function Sidebar() {
return (
<main className="w-[300px] px-4 py-5 text-center text-gray-700">
<Logo />
<div>Sidebar</div>
<p className="mt-2 opacity-50 text-blue-600">This is the sidebar page</p>
</main>
)
}

12
src/sidebar/index.html Normal file
View file

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

13
src/sidebar/main.tsx Normal file
View file

@ -0,0 +1,13 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import '@/styles/main.css'
import App from './Sidebar'
const container = document.getElementById('app')
const root = createRoot(container!)
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
)

78
src/styles/main.css Normal file
View file

@ -0,0 +1,78 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--ring: 215 20.2% 65.1%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 85.7% 97.3%;
--ring: 217.2 32.6% 17.5%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

6
src/utils/index.ts Normal file
View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

71
tailwind.config.ts Normal file
View file

@ -0,0 +1,71 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: ['./pages/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './app/**/*.{ts,tsx}', './src/**/*.{ts,tsx}'],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--radix-accordion-content-height)' }
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 }
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
}
}
},
plugins: [require('tailwindcss-animate')]
}

4
tsconfig.eslint.json Normal file
View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["**/*.ts", "**/*.js", "**/*.tsx", "**/*.jsx"]
}

22
tsconfig.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"baseUrl": ".",
"module": "ESNext",
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"incremental": false,
"skipLibCheck": true,
"jsx": "react-jsx",
"moduleResolution": "Node",
"resolveJsonModule": true,
"noUnusedLocals": true,
"forceConsistentCasingInFileNames": true,
"types": ["vite/client", "unplugin-icons/types/react"],
"paths": {
"@/*": ["src/*"]
}
},
"exclude": ["dist", "node_modules"]
}

34
vite.config.background.ts Normal file
View file

@ -0,0 +1,34 @@
import { defineConfig } from 'vite'
import { sharedConfig } from './vite.config'
import { isDev, r } from './scripts/utils'
import packageJson from './package.json'
// bundling the content script using Vite
export default defineConfig({
...sharedConfig,
define: {
__DEV__: isDev,
__NAME__: JSON.stringify(packageJson.name),
// https://github.com/vitejs/vite/issues/9320
// https://github.com/vitejs/vite/issues/9186
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production')
},
build: {
watch: isDev ? {} : undefined,
outDir: r('extension/dist/background'),
cssCodeSplit: false,
emptyOutDir: false,
sourcemap: isDev ? 'inline' : false,
lib: {
entry: r('src/background/main.ts'),
name: packageJson.name,
formats: ['iife']
},
rollupOptions: {
output: {
entryFileNames: 'index.mjs',
extend: true
}
}
}
})

34
vite.config.content.ts Normal file
View file

@ -0,0 +1,34 @@
import { defineConfig } from 'vite'
import { sharedConfig } from './vite.config'
import { isDev, r } from './scripts/utils'
import packageJson from './package.json'
// bundling the content script using Vite
export default defineConfig({
...sharedConfig,
define: {
__DEV__: isDev,
__NAME__: JSON.stringify(packageJson.name),
// https://github.com/vitejs/vite/issues/9320
// https://github.com/vitejs/vite/issues/9186
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production')
},
build: {
watch: isDev ? {} : undefined,
outDir: r('extension/dist/contentScripts'),
cssCodeSplit: false,
emptyOutDir: false,
sourcemap: isDev ? 'inline' : false,
lib: {
entry: r('src/contentScripts/index.ts'),
name: packageJson.name,
formats: ['iife']
},
rollupOptions: {
output: {
entryFileNames: 'index.global.js',
extend: true
}
}
}
})

71
vite.config.ts Normal file
View file

@ -0,0 +1,71 @@
import { dirname, relative } from 'node:path'
import type { UserConfig } from 'vite'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import Icons from 'unplugin-icons/vite'
import { isDev, port, r } from './scripts/utils'
import packageJson from './package.json'
export const sharedConfig: UserConfig = {
root: r('src'),
resolve: {
alias: {
'@': `${r('src')}/`
}
},
define: {
__DEV__: isDev,
__NAME__: JSON.stringify(packageJson.name)
},
plugins: [
react(),
// https://github.com/antfu/unplugin-icons
Icons({ compiler: 'jsx', jsx: 'react' }),
// 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: {
include: ['react', 'webextension-polyfill']
}
}
export default defineConfig(({ command }) => ({
...sharedConfig,
base: command === 'serve' ? `http://localhost:${port}/` : '/dist/',
server: {
port,
hmr: {
host: 'localhost'
}
},
build: {
watch: isDev ? {} : undefined,
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: {
options: r('src/options/index.html'),
popup: r('src/popup/index.html'),
sidebar: r('src/sidebar/index.html')
}
}
},
test: {
globals: true,
environment: 'jsdom'
}
}))