├── .nvmrc ├── .example.env ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── workflows │ ├── auto-assign.yml │ ├── lint.yml │ ├── greetings.yml │ ├── build-zip.yml │ ├── prettier.yml │ └── e2e.yml ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── pull_request_template.md ├── auto_assign.yml └── stale.yml ├── chrome-extension ├── public │ ├── content.css │ ├── icon-128.png │ └── icon-34.png ├── tsconfig.json ├── src │ └── background │ │ └── index.ts ├── package.json ├── vite.config.mts ├── utils │ └── plugins │ │ └── make-manifest-plugin.ts └── manifest.js ├── packages ├── i18n │ ├── .gitignore │ ├── .eslintignore │ ├── build.dev.mjs │ ├── build.prod.mjs │ ├── tsconfig.json │ ├── index.ts │ ├── lib │ │ ├── i18n-prod.ts │ │ ├── type.ts │ │ ├── getMessageFromLocale.ts │ │ └── i18n-dev.ts │ ├── locales │ │ ├── ko │ │ │ └── messages.json │ │ └── en │ │ │ └── messages.json │ ├── build.mjs │ ├── package.json │ ├── genenrate-i18n.mjs │ └── README.md ├── storage │ ├── index.ts │ ├── .eslintignore │ ├── lib │ │ ├── impl │ │ │ ├── index.ts │ │ │ └── exampleThemeStorage.ts │ │ ├── index.ts │ │ └── base │ │ │ ├── types.ts │ │ │ ├── enums.ts │ │ │ └── base.ts │ ├── tsconfig.json │ ├── build.mjs │ └── package.json ├── dev-utils │ ├── .eslintignore │ ├── index.ts │ ├── lib │ │ ├── manifest-parser │ │ │ ├── index.ts │ │ │ ├── type.ts │ │ │ └── impl.ts │ │ └── logger.ts │ ├── tsconfig.json │ └── package.json ├── hmr │ ├── index.ts │ ├── lib │ │ ├── plugins │ │ │ ├── index.ts │ │ │ ├── watch-public-plugin.ts │ │ │ ├── watch-rebuild-plugin.ts │ │ │ └── make-entry-point-plugin.ts │ │ ├── constant.ts │ │ ├── injections │ │ │ ├── reload.ts │ │ │ └── refresh.ts │ │ ├── interpreter │ │ │ └── index.ts │ │ ├── types.ts │ │ └── initializers │ │ │ ├── initClient.ts │ │ │ └── initReloadServer.ts │ ├── tsconfig.json │ ├── tsconfig.build.json │ ├── rollup.config.mjs │ └── package.json ├── shared │ ├── .eslintignore │ ├── lib │ │ ├── hooks │ │ │ ├── index.ts │ │ │ └── useStorage.tsx │ │ ├── utils │ │ │ ├── index.ts │ │ │ └── shared-types.ts │ │ └── hoc │ │ │ ├── index.ts │ │ │ ├── withSuspense.tsx │ │ │ └── withErrorBoundary.tsx │ ├── index.ts │ ├── tsconfig.json │ ├── README.md │ ├── build.mjs │ └── package.json ├── zipper │ ├── .eslintignore │ ├── tsconfig.json │ ├── index.ts │ ├── package.json │ └── lib │ │ └── zip-bundle │ │ └── index.ts ├── ui │ ├── lib │ │ ├── components │ │ │ ├── index.ts │ │ │ └── Button.tsx │ │ ├── global.css │ │ ├── utils.ts │ │ └── withUI.ts │ ├── index.ts │ ├── tsconfig.json │ ├── build.mjs │ ├── package.json │ └── README.md ├── vite-config │ ├── index.mjs │ ├── lib │ │ ├── env.mjs │ │ └── withPageConfig.mjs │ └── package.json ├── tsconfig │ ├── app.json │ ├── package.json │ ├── utils.json │ └── base.json └── tailwind-config │ ├── tailwind.config.ts │ └── package.json ├── .husky └── pre-commit ├── .npmrc ├── .eslintignore ├── pages ├── content-runtime │ ├── src │ │ ├── index.css │ │ ├── index.ts │ │ ├── App.tsx │ │ └── Root.tsx │ ├── tsconfig.json │ ├── vite.config.mts │ └── package.json ├── side-panel │ ├── src │ │ ├── index.css │ │ ├── index.tsx │ │ ├── SidePanel.css │ │ └── SidePanel.tsx │ ├── tailwind.config.ts │ ├── tsconfig.json │ ├── index.html │ ├── vite.config.mts │ └── package.json ├── content-ui │ ├── src │ │ ├── tailwind-input.css │ │ ├── App.tsx │ │ └── index.tsx │ ├── tailwind.config.ts │ ├── tsconfig.json │ ├── vite.config.mts │ ├── package.json │ └── public │ │ └── logo.svg ├── new-tab │ ├── src │ │ ├── NewTab.scss │ │ ├── NewTab.css │ │ ├── index.tsx │ │ ├── index.css │ │ └── NewTab.tsx │ ├── tailwind.config.ts │ ├── tsconfig.json │ ├── index.html │ ├── vite.config.mts │ └── package.json ├── content │ ├── src │ │ ├── index.ts │ │ └── toggleTheme.ts │ ├── tsconfig.json │ ├── vite.config.mts │ ├── package.json │ └── public │ │ └── logo.svg ├── devtools │ ├── src │ │ └── index.ts │ ├── index.html │ ├── tsconfig.json │ ├── vite.config.mts │ ├── package.json │ └── public │ │ └── logo.svg ├── options │ ├── tailwind.config.ts │ ├── tsconfig.json │ ├── index.html │ ├── src │ │ ├── index.css │ │ ├── index.tsx │ │ ├── Options.css │ │ └── Options.tsx │ ├── vite.config.mts │ └── package.json ├── popup │ ├── tailwind.config.ts │ ├── tsconfig.json │ ├── index.html │ ├── src │ │ ├── index.tsx │ │ ├── index.css │ │ ├── Popup.css │ │ └── Popup.tsx │ ├── vite.config.mts │ └── package.json └── devtools-panel │ ├── tailwind.config.ts │ ├── tsconfig.json │ ├── index.html │ ├── src │ ├── index.tsx │ ├── index.css │ ├── Panel.css │ └── Panel.tsx │ ├── vite.config.mts │ └── package.json ├── pnpm-workspace.yaml ├── .prettierignore ├── vite-env.d.ts ├── .prettierrc ├── tests └── e2e │ ├── specs │ ├── smoke.test.ts │ ├── page-content-ui.test.ts │ ├── page-popup.test.ts │ ├── page-options.test.ts │ ├── page-side-panel.test.ts │ ├── page-dev-tools.test.ts │ ├── page-new-tab.test.ts │ ├── page-content.test.ts │ └── page-content-runtime.test.ts │ ├── config │ ├── wdio.d.ts │ ├── wdio.browser.conf.ts │ └── wdio.conf.ts │ ├── tsconfig.json │ ├── package.json │ ├── helpers │ └── theme.ts │ └── utils │ └── extension-path.ts ├── UPDATE-PACKAGE-VERSIONS.md ├── .gitignore ├── update_version.sh ├── .eslintrc ├── LICENSE ├── turbo.json ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.19.1 2 | -------------------------------------------------------------------------------- /.example.env: -------------------------------------------------------------------------------- 1 | VITE_EXAMPLE=example -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @Jonghakseo -------------------------------------------------------------------------------- /chrome-extension/public/content.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: Jonghakseo 2 | -------------------------------------------------------------------------------- /packages/i18n/.gitignore: -------------------------------------------------------------------------------- 1 | lib/i18n.ts 2 | -------------------------------------------------------------------------------- /packages/i18n/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /packages/storage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib'; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm dlx lint-staged --allow-empty 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=@testing-library/dom 2 | -------------------------------------------------------------------------------- /packages/dev-utils/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /packages/hmr/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/plugins'; 2 | -------------------------------------------------------------------------------- /packages/shared/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /packages/storage/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /packages/zipper/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | tailwind.config.ts 4 | -------------------------------------------------------------------------------- /packages/shared/lib/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useStorage'; 2 | -------------------------------------------------------------------------------- /packages/ui/lib/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Button'; 2 | -------------------------------------------------------------------------------- /packages/shared/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './shared-types'; 2 | -------------------------------------------------------------------------------- /packages/storage/lib/impl/index.ts: -------------------------------------------------------------------------------- 1 | export * from './exampleThemeStorage'; 2 | -------------------------------------------------------------------------------- /packages/shared/lib/utils/shared-types.ts: -------------------------------------------------------------------------------- 1 | export type ValueOf = T[keyof T]; 2 | -------------------------------------------------------------------------------- /packages/ui/lib/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /pages/content-runtime/src/index.css: -------------------------------------------------------------------------------- 1 | .runtime-content-view-text { 2 | font-size: 20px; 3 | } 4 | -------------------------------------------------------------------------------- /pages/side-panel/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /packages/dev-utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/manifest-parser'; 2 | export * from './lib/logger'; 3 | -------------------------------------------------------------------------------- /packages/storage/lib/index.ts: -------------------------------------------------------------------------------- 1 | export type { BaseStorage } from './base/types'; 2 | export * from './impl'; 3 | -------------------------------------------------------------------------------- /packages/vite-config/index.mjs: -------------------------------------------------------------------------------- 1 | export * from './lib/env.mjs'; 2 | export * from './lib/withPageConfig.mjs'; 3 | -------------------------------------------------------------------------------- /pages/content-ui/src/tailwind-input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "chrome-extension" 3 | - "pages/*" 4 | - "packages/*" 5 | - "tests/*" 6 | -------------------------------------------------------------------------------- /packages/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/hooks'; 2 | export * from './lib/hoc'; 3 | export * from './lib/utils'; 4 | -------------------------------------------------------------------------------- /packages/ui/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/components'; 2 | export * from './lib/utils'; 3 | export * from './lib/withUI'; 4 | -------------------------------------------------------------------------------- /packages/vite-config/lib/env.mjs: -------------------------------------------------------------------------------- 1 | export const isDev = process.env.__DEV__ === 'true'; 2 | export const isProduction = !isDev; 3 | -------------------------------------------------------------------------------- /pages/content-runtime/src/index.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@src/Root'; 2 | 3 | mount(); 4 | console.log('runtime script loaded'); 5 | -------------------------------------------------------------------------------- /pages/new-tab/src/NewTab.scss: -------------------------------------------------------------------------------- 1 | $myColor: red; 2 | 3 | h1, 4 | h2, 5 | h3, 6 | h4, 7 | h5, 8 | h6 { 9 | color: $myColor; 10 | } 11 | -------------------------------------------------------------------------------- /packages/dev-utils/lib/manifest-parser/index.ts: -------------------------------------------------------------------------------- 1 | import { ManifestParserImpl } from './impl'; 2 | export const ManifestParser = ManifestParserImpl; 3 | -------------------------------------------------------------------------------- /chrome-extension/public/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pranesh239/chrome-extension-boilerplate-react-vite/main/chrome-extension/public/icon-128.png -------------------------------------------------------------------------------- /chrome-extension/public/icon-34.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pranesh239/chrome-extension-boilerplate-react-vite/main/chrome-extension/public/icon-34.png -------------------------------------------------------------------------------- /pages/content/src/index.ts: -------------------------------------------------------------------------------- 1 | import { toggleTheme } from '@src/toggleTheme'; 2 | 3 | console.log('content script loaded'); 4 | 5 | void toggleTheme(); 6 | -------------------------------------------------------------------------------- /packages/hmr/lib/plugins/index.ts: -------------------------------------------------------------------------------- 1 | export * from './watch-rebuild-plugin'; 2 | export * from './make-entry-point-plugin'; 3 | export * from './watch-public-plugin'; 4 | -------------------------------------------------------------------------------- /packages/tsconfig/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Chrome Extension App", 4 | "extends": "./base.json" 5 | } 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .gitignore 4 | .github 5 | .eslintignore 6 | .husky 7 | .nvmrc 8 | .prettierignore 9 | LICENSE 10 | *.md 11 | pnpm-lock.yaml -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/tsconfig", 3 | "version": "0.3.4", 4 | "description": "chrome extension - tsconfig", 5 | "private": true 6 | } 7 | -------------------------------------------------------------------------------- /packages/i18n/build.dev.mjs: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { build } from './build.mjs'; 3 | 4 | const i18nPath = path.resolve('lib', 'i18n-dev.ts'); 5 | 6 | void build(i18nPath); 7 | -------------------------------------------------------------------------------- /packages/i18n/build.prod.mjs: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { build } from './build.mjs'; 3 | 4 | const i18nPath = path.resolve('lib', 'i18n-prod.ts'); 5 | 6 | void build(i18nPath); 7 | -------------------------------------------------------------------------------- /packages/shared/lib/hoc/index.ts: -------------------------------------------------------------------------------- 1 | import { withSuspense } from './withSuspense'; 2 | import { withErrorBoundary } from './withErrorBoundary'; 3 | 4 | export { withSuspense, withErrorBoundary }; 5 | -------------------------------------------------------------------------------- /vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_EXAMPLE: string; 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv; 9 | } 10 | -------------------------------------------------------------------------------- /packages/tailwind-config/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss/types/config'; 2 | 3 | export default { 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | } as Omit; 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "semi": true, 4 | "singleQuote": true, 5 | "arrowParens": "avoid", 6 | "printWidth": 120, 7 | "bracketSameLine": true, 8 | "htmlWhitespaceSensitivity": "strict" 9 | } 10 | -------------------------------------------------------------------------------- /pages/content-ui/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import baseConfig from '@extension/tailwindcss-config'; 2 | import { withUI } from '@extension/ui'; 3 | 4 | export default withUI({ 5 | ...baseConfig, 6 | content: ['src/**/*.{ts,tsx}'], 7 | }); 8 | -------------------------------------------------------------------------------- /packages/dev-utils/lib/manifest-parser/type.ts: -------------------------------------------------------------------------------- 1 | export type Manifest = chrome.runtime.ManifestV3; 2 | 3 | export interface ManifestParserInterface { 4 | convertManifestToString: (manifest: Manifest, env: 'chrome' | 'firefox') => string; 5 | } 6 | -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist", 6 | "types": ["chrome"] 7 | }, 8 | "include": ["index.ts", "lib"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/ui/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ClassValue } from 'clsx'; 2 | import { clsx } from 'clsx'; 3 | import { twMerge } from 'tailwind-merge'; 4 | 5 | export const cn = (...inputs: ClassValue[]) => { 6 | return twMerge(clsx(inputs)); 7 | }; 8 | -------------------------------------------------------------------------------- /packages/dev-utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist", 6 | "types": ["chrome"] 7 | }, 8 | "include": ["index.ts", "lib"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/storage/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist", 6 | "types": ["chrome"] 7 | }, 8 | "include": ["index.ts", "lib"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/tailwind-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/tailwindcss-config", 3 | "version": "0.3.4", 4 | "description": "chrome extension - tailwindcss configuration", 5 | "main": "tailwind.config.ts", 6 | "private": true 7 | } 8 | -------------------------------------------------------------------------------- /packages/i18n/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist", 6 | "types": ["chrome"] 7 | }, 8 | "include": ["index.ts", "lib", "locales"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/zipper/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist", 6 | "types": ["chrome", "node"] 7 | }, 8 | "include": ["index.ts", "lib"] 9 | } 10 | -------------------------------------------------------------------------------- /pages/devtools/src/index.ts: -------------------------------------------------------------------------------- 1 | try { 2 | console.log("Edit 'pages/devtools/src/index.ts' and save to reload."); 3 | chrome.devtools.panels.create('Dev Tools', '/icon-34.png', '/devtools-panel/index.html'); 4 | } catch (e) { 5 | console.error(e); 6 | } 7 | -------------------------------------------------------------------------------- /pages/new-tab/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import baseConfig from '@extension/tailwindcss-config'; 2 | import { withUI } from '@extension/ui'; 3 | 4 | export default withUI({ 5 | ...baseConfig, 6 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 7 | }); 8 | -------------------------------------------------------------------------------- /pages/options/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import baseConfig from '@extension/tailwindcss-config'; 2 | import { withUI } from '@extension/ui'; 3 | 4 | export default withUI({ 5 | ...baseConfig, 6 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 7 | }); 8 | -------------------------------------------------------------------------------- /packages/hmr/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist", 6 | "types": ["chrome"] 7 | }, 8 | "include": ["lib", "index.ts", "rollup.config.mjs"] 9 | } 10 | -------------------------------------------------------------------------------- /pages/devtools/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Devtools 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /pages/popup/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import baseConfig from '@extension/tailwindcss-config'; 2 | import type { Config } from 'tailwindcss/types/config'; 3 | 4 | export default { 5 | ...baseConfig, 6 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 7 | } as Config; 8 | -------------------------------------------------------------------------------- /packages/i18n/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 2 | // @ts-ignore 3 | import { t as t_dev_or_prod } from './lib/i18n'; 4 | import type { t as t_dev } from './lib/i18n-dev'; 5 | 6 | export const t = t_dev_or_prod as unknown as typeof t_dev; 7 | -------------------------------------------------------------------------------- /pages/side-panel/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import baseConfig from '@extension/tailwindcss-config'; 2 | import type { Config } from 'tailwindcss/types/config'; 3 | 4 | export default { 5 | ...baseConfig, 6 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 7 | } as Config; 8 | -------------------------------------------------------------------------------- /pages/devtools-panel/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import baseConfig from '@extension/tailwindcss-config'; 2 | import type { Config } from 'tailwindcss/types/config'; 3 | 4 | export default { 5 | ...baseConfig, 6 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 7 | } as Config; 8 | -------------------------------------------------------------------------------- /tests/e2e/specs/smoke.test.ts: -------------------------------------------------------------------------------- 1 | describe('The example page can be loaded', () => { 2 | it('should be able to go to example page', async () => { 3 | await browser.url('https://www.example.com'); 4 | 5 | await expect(browser).toHaveTitle('Example Domain'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /pages/content/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /pages/devtools/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /pages/new-tab/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /pages/options/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /pages/popup/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /chrome-extension/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/app", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | } 8 | }, 9 | "include": ["src", "utils", "vite.config.mts", "../node_modules/@types"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/hmr/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist", 6 | "types": ["chrome"] 7 | }, 8 | "exclude": ["lib/injections/**/*"], 9 | "include": ["lib", "index.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /pages/content-ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /pages/side-panel/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "baseUrl": ".", 6 | "types": ["chrome"], 7 | "paths": { 8 | "@/*": ["./*"] 9 | } 10 | }, 11 | "include": ["index.ts", "lib"] 12 | } 13 | -------------------------------------------------------------------------------- /pages/content-runtime/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /pages/devtools-panel/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /pages/content-runtime/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export default function App() { 4 | useEffect(() => { 5 | console.log('runtime content view loaded'); 6 | }, []); 7 | 8 | return
runtime content view
; 9 | } 10 | -------------------------------------------------------------------------------- /packages/hmr/lib/constant.ts: -------------------------------------------------------------------------------- 1 | export const LOCAL_RELOAD_SOCKET_PORT = 8081; 2 | export const LOCAL_RELOAD_SOCKET_URL = `ws://localhost:${LOCAL_RELOAD_SOCKET_PORT}`; 3 | 4 | export const DO_UPDATE = 'do_update'; 5 | export const DONE_UPDATE = 'done_update'; 6 | export const BUILD_COMPLETE = 'build_complete'; 7 | -------------------------------------------------------------------------------- /packages/i18n/lib/i18n-prod.ts: -------------------------------------------------------------------------------- 1 | import type { DevLocale, MessageKey } from './type'; 2 | 3 | export function t(key: MessageKey, substitutions?: string | string[]) { 4 | return chrome.i18n.getMessage(key, substitutions); 5 | } 6 | 7 | t.devLocale = '' as DevLocale; // for type consistency with i18n-dev.ts 8 | -------------------------------------------------------------------------------- /pages/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Popup 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /pages/new-tab/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | New Tab 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /pages/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Options 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/ui/lib/withUI.ts: -------------------------------------------------------------------------------- 1 | import deepmerge from 'deepmerge'; 2 | import type { Config } from 'tailwindcss/types/config'; 3 | 4 | export function withUI(tailwindConfig: Config): Config { 5 | return deepmerge(tailwindConfig, { 6 | content: ['./node_modules/@extension/ui/lib/**/*.{tsx,ts,js,jsx}'], 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /pages/side-panel/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Side Panel 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/shared/README.md: -------------------------------------------------------------------------------- 1 | # Shared Package 2 | 3 | This package contains code shared with other packages. 4 | To use the code in the package, you need to add the following to the package.json file. 5 | 6 | ```json 7 | { 8 | "dependencies": { 9 | "@extension/shared": "workspace:*" 10 | } 11 | } 12 | ``` 13 | -------------------------------------------------------------------------------- /pages/devtools-panel/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Devtools Panel 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /pages/content/src/toggleTheme.ts: -------------------------------------------------------------------------------- 1 | import { exampleThemeStorage } from '@extension/storage'; 2 | 3 | export async function toggleTheme() { 4 | console.log('initial theme:', await exampleThemeStorage.get()); 5 | await exampleThemeStorage.toggle(); 6 | console.log('toggled theme:', await exampleThemeStorage.get()); 7 | } 8 | -------------------------------------------------------------------------------- /tests/e2e/config/wdio.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace WebdriverIO { 2 | interface Browser extends WebdriverIO.Browser { 3 | getExtensionPath: () => Promise; 4 | installAddOn: (extension: string, temporary: boolean) => Promise; 5 | addCommand: (name: string, func: () => Promise) => void; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/auto-assign.yml: -------------------------------------------------------------------------------- 1 | name: 'Auto Assign' 2 | on: 3 | pull_request: 4 | types: [opened, ready_for_review] 5 | 6 | jobs: 7 | add-reviews: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: kentaro-m/auto-assign-action@v1.2.5 11 | with: 12 | configuration-path: '.github/auto_assign.yml' 13 | -------------------------------------------------------------------------------- /UPDATE-PACKAGE-VERSIONS.md: -------------------------------------------------------------------------------- 1 | For update package version in all ```package.json``` files use this command in root: 2 | 3 | FOR WINDOWS YOU NEED TO USE E.G ```GIT BASH``` CONSOLE OR OTHER WHICH SUPPORT UNIX COMMANDS 4 | ```bash 5 | pnpm update-version 6 | ``` 7 | 8 | If script was run successfully you will see ```Updated versions to ``` 9 | -------------------------------------------------------------------------------- /chrome-extension/src/background/index.ts: -------------------------------------------------------------------------------- 1 | import 'webextension-polyfill'; 2 | import { exampleThemeStorage } from '@extension/storage'; 3 | 4 | exampleThemeStorage.get().then(theme => { 5 | console.log('theme', theme); 6 | }); 7 | 8 | console.log('background loaded'); 9 | console.log("Edit 'chrome-extension/src/background/index.ts' and save to reload."); 10 | -------------------------------------------------------------------------------- /packages/zipper/index.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { zipBundle } from './lib/zip-bundle'; 3 | 4 | // package the root dist file 5 | zipBundle({ 6 | distDirectory: resolve(__dirname, '../../dist'), 7 | buildDirectory: resolve(__dirname, '../../dist-zip'), 8 | archiveName: process.env.__FIREFOX__ ? 'extension.xpi' : 'extension.zip', 9 | }); 10 | -------------------------------------------------------------------------------- /packages/tsconfig/utils.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Chrome Extension Utils", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "noEmit": false, 7 | "declaration": true, 8 | "module": "CommonJS", 9 | "moduleResolution": "Node", 10 | "target": "ES6", 11 | "types": ["node"] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/e2e/specs/page-content-ui.test.ts: -------------------------------------------------------------------------------- 1 | describe('Content UI Injection', () => { 2 | it('should locate the injected content UI div', async () => { 3 | await browser.url('https://www.example.com'); 4 | 5 | const contentDiv = await $('#chrome-extension-boilerplate-react-vite-content-view-root').getElement(); 6 | await expect(contentDiv).toBeDisplayed(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /packages/hmr/lib/injections/reload.ts: -------------------------------------------------------------------------------- 1 | import initClient from '../initializers/initClient'; 2 | 3 | function addReload() { 4 | const reload = () => { 5 | chrome.runtime.reload(); 6 | }; 7 | 8 | initClient({ 9 | // @ts-expect-error That's because of the dynamic code loading 10 | id: __HMR_ID, 11 | onUpdate: reload, 12 | }); 13 | } 14 | 15 | addReload(); 16 | -------------------------------------------------------------------------------- /packages/i18n/lib/type.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is generated by generate-i18n.mjs 3 | * Do not edit this file directly 4 | */ 5 | import type enMessage from '../locales/en/messages.json'; 6 | import type koMessage from '../locales/ko/messages.json'; 7 | 8 | export type MessageKey = keyof typeof enMessage & keyof typeof koMessage; 9 | 10 | export type DevLocale = 'en' | 'ko'; 11 | -------------------------------------------------------------------------------- /packages/storage/build.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | 3 | /** 4 | * @type { import('esbuild').BuildOptions } 5 | */ 6 | const buildOptions = { 7 | entryPoints: ['./index.ts', './lib/**/*.ts'], 8 | tsconfig: './tsconfig.json', 9 | bundle: false, 10 | target: 'es6', 11 | outdir: './dist', 12 | sourcemap: true, 13 | }; 14 | 15 | await esbuild.build(buildOptions); 16 | -------------------------------------------------------------------------------- /pages/options/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 8 | 'Droid Sans', 'Helvetica Neue', sans-serif; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | } 12 | -------------------------------------------------------------------------------- /packages/shared/build.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | 3 | /** 4 | * @type { import('esbuild').BuildOptions } 5 | */ 6 | const buildOptions = { 7 | entryPoints: ['./index.ts', './lib/**/*.ts', './lib/**/*.tsx'], 8 | tsconfig: './tsconfig.json', 9 | bundle: false, 10 | target: 'es6', 11 | outdir: './dist', 12 | sourcemap: true, 13 | }; 14 | 15 | await esbuild.build(buildOptions); 16 | -------------------------------------------------------------------------------- /pages/popup/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import '@src/index.css'; 3 | import Popup from '@src/Popup'; 4 | 5 | function init() { 6 | const appContainer = document.querySelector('#app-container'); 7 | if (!appContainer) { 8 | throw new Error('Can not find #app-container'); 9 | } 10 | const root = createRoot(appContainer); 11 | 12 | root.render(); 13 | } 14 | 15 | init(); 16 | -------------------------------------------------------------------------------- /packages/hmr/lib/plugins/watch-public-plugin.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from 'vite'; 2 | import fg from 'fast-glob'; 3 | 4 | export function watchPublicPlugin(): PluginOption { 5 | return { 6 | name: 'watch-public-plugin', 7 | async buildStart() { 8 | const files = await fg(['public/**/*']); 9 | 10 | for (const file of files) { 11 | this.addWatchFile(file); 12 | } 13 | }, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /pages/devtools-panel/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import '@src/index.css'; 3 | import Panel from '@src/Panel'; 4 | 5 | function init() { 6 | const appContainer = document.querySelector('#app-container'); 7 | if (!appContainer) { 8 | throw new Error('Can not find #app-container'); 9 | } 10 | const root = createRoot(appContainer); 11 | 12 | root.render(); 13 | } 14 | 15 | init(); 16 | -------------------------------------------------------------------------------- /pages/side-panel/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import '@src/index.css'; 3 | import SidePanel from '@src/SidePanel'; 4 | 5 | function init() { 6 | const appContainer = document.querySelector('#app-container'); 7 | if (!appContainer) { 8 | throw new Error('Can not find #app-container'); 9 | } 10 | const root = createRoot(appContainer); 11 | root.render(); 12 | } 13 | 14 | init(); 15 | -------------------------------------------------------------------------------- /pages/new-tab/src/NewTab.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | } 8 | 9 | .App-header { 10 | min-height: 100vh; 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | justify-content: center; 15 | font-size: calc(10px + 2vmin); 16 | } 17 | 18 | code { 19 | background: rgba(148, 163, 184, 0.5); 20 | border-radius: 0.25rem; 21 | padding: 0.2rem 0.5rem; 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | **/node_modules 3 | 4 | # testing 5 | **/coverage 6 | 7 | # build 8 | **/dist 9 | **/build 10 | **/dist-zip 11 | 12 | # env 13 | **/.env.* 14 | **/.env 15 | 16 | # etc 17 | .DS_Store 18 | .idea 19 | **/.turbo 20 | 21 | # compiled 22 | chrome-extension/public/manifest.json 23 | **/tailwind-output.css 24 | 25 | # vite timestamp (because of bug from lib, remove it after fix will have been realased) 26 | **/vite.config.mts.timestamp-* -------------------------------------------------------------------------------- /tests/e2e/specs/page-popup.test.ts: -------------------------------------------------------------------------------- 1 | import { canSwitchTheme } from '../helpers/theme'; 2 | 3 | describe('Webextension Popup', () => { 4 | it('should open the popup successfully', async () => { 5 | const extensionPath = await browser.getExtensionPath(); 6 | const popupUrl = `${extensionPath}/popup/index.html`; 7 | await browser.url(popupUrl); 8 | 9 | await expect(browser).toHaveTitle('Popup'); 10 | await canSwitchTheme(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /pages/new-tab/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import '@src/index.css'; 3 | import '@extension/ui/lib/global.css'; 4 | import NewTab from '@src/NewTab'; 5 | 6 | function init() { 7 | const appContainer = document.querySelector('#app-container'); 8 | if (!appContainer) { 9 | throw new Error('Can not find #app-container'); 10 | } 11 | const root = createRoot(appContainer); 12 | 13 | root.render(); 14 | } 15 | 16 | init(); 17 | -------------------------------------------------------------------------------- /pages/options/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import '@src/index.css'; 3 | import '@extension/ui/dist/global.css'; 4 | import Options from '@src/Options'; 5 | 6 | function init() { 7 | const appContainer = document.querySelector('#app-container'); 8 | if (!appContainer) { 9 | throw new Error('Can not find #app-container'); 10 | } 11 | const root = createRoot(appContainer); 12 | root.render(); 13 | } 14 | 15 | init(); 16 | -------------------------------------------------------------------------------- /pages/popup/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { withPageConfig } from '@extension/vite-config'; 3 | 4 | const rootDir = resolve(__dirname); 5 | const srcDir = resolve(rootDir, 'src'); 6 | 7 | export default withPageConfig({ 8 | resolve: { 9 | alias: { 10 | '@src': srcDir, 11 | }, 12 | }, 13 | publicDir: resolve(rootDir, 'public'), 14 | build: { 15 | outDir: resolve(rootDir, '..', '..', 'dist', 'popup'), 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /pages/devtools/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { withPageConfig } from '@extension/vite-config'; 3 | 4 | const rootDir = resolve(__dirname); 5 | const srcDir = resolve(rootDir, 'src'); 6 | 7 | export default withPageConfig({ 8 | resolve: { 9 | alias: { 10 | '@src': srcDir, 11 | }, 12 | }, 13 | publicDir: resolve(rootDir, 'public'), 14 | build: { 15 | outDir: resolve(rootDir, '..', '..', 'dist', 'devtools'), 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /pages/new-tab/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { withPageConfig } from '@extension/vite-config'; 3 | 4 | const rootDir = resolve(__dirname); 5 | const srcDir = resolve(rootDir, 'src'); 6 | 7 | export default withPageConfig({ 8 | resolve: { 9 | alias: { 10 | '@src': srcDir, 11 | }, 12 | }, 13 | publicDir: resolve(rootDir, 'public'), 14 | build: { 15 | outDir: resolve(rootDir, '..', '..', 'dist', 'new-tab'), 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /pages/options/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { withPageConfig } from '@extension/vite-config'; 3 | 4 | const rootDir = resolve(__dirname); 5 | const srcDir = resolve(rootDir, 'src'); 6 | 7 | export default withPageConfig({ 8 | resolve: { 9 | alias: { 10 | '@src': srcDir, 11 | }, 12 | }, 13 | publicDir: resolve(rootDir, 'public'), 14 | build: { 15 | outDir: resolve(rootDir, '..', '..', 'dist', 'options'), 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /pages/side-panel/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { withPageConfig } from '@extension/vite-config'; 3 | 4 | const rootDir = resolve(__dirname); 5 | const srcDir = resolve(rootDir, 'src'); 6 | 7 | export default withPageConfig({ 8 | resolve: { 9 | alias: { 10 | '@src': srcDir, 11 | }, 12 | }, 13 | publicDir: resolve(rootDir, 'public'), 14 | build: { 15 | outDir: resolve(rootDir, '..', '..', 'dist', 'side-panel'), 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /tests/e2e/specs/page-options.test.ts: -------------------------------------------------------------------------------- 1 | import { canSwitchTheme } from '../helpers/theme'; 2 | 3 | describe('Webextension Options Page', () => { 4 | it('should make options page accessible', async () => { 5 | const extensionPath = await browser.getExtensionPath(); 6 | const optionsUrl = `${extensionPath}/options/index.html`; 7 | 8 | await browser.url(optionsUrl); 9 | 10 | await expect(browser).toHaveTitle('Options'); 11 | await canSwitchTheme(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /tests/e2e/specs/page-side-panel.test.ts: -------------------------------------------------------------------------------- 1 | import { canSwitchTheme } from '../helpers/theme'; 2 | 3 | describe('Webextension Side Panel', () => { 4 | it('should make side panel accessible', async () => { 5 | const extensionPath = await browser.getExtensionPath(); 6 | const sidePanelUrl = `${extensionPath}/side-panel/index.html`; 7 | 8 | await browser.url(sidePanelUrl); 9 | await expect(browser).toHaveTitle('Side Panel'); 10 | await canSwitchTheme(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/shared/lib/hoc/withSuspense.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentType, ReactElement } from 'react'; 2 | import { Suspense } from 'react'; 3 | 4 | export function withSuspense>( 5 | Component: ComponentType, 6 | SuspenseComponent: ReactElement, 7 | ) { 8 | return function WithSuspense(props: T) { 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /pages/devtools-panel/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { withPageConfig } from '@extension/vite-config'; 3 | 4 | const rootDir = resolve(__dirname); 5 | const srcDir = resolve(rootDir, 'src'); 6 | 7 | export default withPageConfig({ 8 | resolve: { 9 | alias: { 10 | '@src': srcDir, 11 | }, 12 | }, 13 | publicDir: resolve(rootDir, 'public'), 14 | build: { 15 | outDir: resolve(rootDir, '..', '..', 'dist', 'devtools-panel'), 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /pages/new-tab/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 8 | 'Droid Sans', 'Helvetica Neue', sans-serif; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | } 12 | 13 | code { 14 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint Check 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | eslint: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: pnpm/action-setup@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version-file: '.nvmrc' 19 | cache: pnpm 20 | 21 | - run: pnpm install --frozen-lockfile --prefer-offline 22 | 23 | - run: pnpm lint 24 | -------------------------------------------------------------------------------- /tests/e2e/specs/page-dev-tools.test.ts: -------------------------------------------------------------------------------- 1 | import { canSwitchTheme } from '../helpers/theme'; 2 | 3 | describe('Webextension DevTools Panel', () => { 4 | it('should make DevTools panel available', async () => { 5 | const extensionPath = await browser.getExtensionPath(); 6 | const devtoolsPanelUrl = `${extensionPath}/devtools-panel/index.html`; 7 | 8 | await browser.url(devtoolsPanelUrl); 9 | await expect(browser).toHaveTitle('Devtools Panel'); 10 | await canSwitchTheme(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/hmr/lib/interpreter/index.ts: -------------------------------------------------------------------------------- 1 | import type { SerializedMessage, WebSocketMessage } from '../types'; 2 | 3 | export default class MessageInterpreter { 4 | // eslint-disable-next-line @typescript-eslint/no-empty-function 5 | private constructor() {} 6 | 7 | static send(message: WebSocketMessage): SerializedMessage { 8 | return JSON.stringify(message); 9 | } 10 | 11 | static receive(serializedMessage: SerializedMessage): WebSocketMessage { 12 | return JSON.parse(serializedMessage); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pages/devtools-panel/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 8 | 'Droid Sans', 'Helvetica Neue', sans-serif; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | 12 | position: relative; 13 | } 14 | 15 | code { 16 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 17 | } 18 | -------------------------------------------------------------------------------- /pages/options/src/Options.css: -------------------------------------------------------------------------------- 1 | #app-container { 2 | text-align: center; 3 | width: 100vw; 4 | height: 100vh; 5 | } 6 | 7 | .App-logo { 8 | height: 40vmin; 9 | pointer-events: none; 10 | } 11 | 12 | .App { 13 | width: 100vw; 14 | height: 100vh; 15 | font-size: calc(10px + 2vmin); 16 | display: flex; 17 | flex-direction: column; 18 | align-items: center; 19 | justify-content: center; 20 | } 21 | 22 | code { 23 | background: rgba(148, 163, 184, 0.5); 24 | border-radius: 0.25rem; 25 | padding: 0.2rem 0.5rem; 26 | } 27 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /tests/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "moduleResolution": "node", 5 | "module": "ESNext", 6 | "target": "es2022", 7 | "lib": ["es2022", "dom"], 8 | "types": ["node", "@wdio/globals/types", "@wdio/mocha-framework"], 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noFallthroughCasesInSwitch": true 15 | }, 16 | "include": ["specs", "config", "helpers"] 17 | } 18 | -------------------------------------------------------------------------------- /packages/vite-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/vite-config", 3 | "version": "0.3.4", 4 | "description": "chrome extension - vite base configuration", 5 | "main": "index.mjs", 6 | "type": "module", 7 | "private": true, 8 | "scripts": { 9 | "clean:node_modules": "pnpx rimraf node_modules", 10 | "clean": "pnpm clean:node_modules" 11 | }, 12 | "devDependencies": { 13 | "@extension/hmr": "workspace:*", 14 | "@extension/tsconfig": "workspace:*", 15 | "@vitejs/plugin-react-swc": "^3.7.1", 16 | "deepmerge": "^4.3.1" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /pages/popup/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | width: 300px; 7 | height: 260px; 8 | margin: 0; 9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 10 | 'Droid Sans', 'Helvetica Neue', sans-serif; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | 14 | position: relative; 15 | } 16 | 17 | code { 18 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/greetings.yml: -------------------------------------------------------------------------------- 1 | name: Greetings 2 | 3 | on: [pull_request_target, issues] 4 | 5 | jobs: 6 | greeting: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | steps: 12 | - uses: actions/first-interaction@v1 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | issue-message: 'Thank you for your contribution. We will check and reply to you as soon as possible.' 16 | pr-message: 'Thank you for your contribution. We will check and reply to you as soon as possible.' 17 | -------------------------------------------------------------------------------- /pages/devtools-panel/src/Panel.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | height: 100vh; 4 | width: 100%; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | padding: 2rem; 9 | } 10 | 11 | .App-logo { 12 | height: 40vmin; 13 | } 14 | 15 | .App-header { 16 | height: 100%; 17 | display: flex; 18 | flex-direction: column; 19 | align-items: center; 20 | justify-content: center; 21 | font-size: calc(10px + 2vmin); 22 | } 23 | 24 | code { 25 | background: rgba(148, 163, 184, 0.5); 26 | border-radius: 0.25rem; 27 | padding: 0.2rem 0.5rem; 28 | } 29 | -------------------------------------------------------------------------------- /pages/popup/src/Popup.css: -------------------------------------------------------------------------------- 1 | .App { 2 | position: absolute; 3 | top: 0; 4 | bottom: 0; 5 | left: 0; 6 | right: 0; 7 | text-align: center; 8 | height: 100%; 9 | padding: 1rem; 10 | } 11 | 12 | .App-logo { 13 | height: 50vmin; 14 | margin-bottom: 1rem; 15 | } 16 | 17 | .App-header { 18 | height: 100%; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: flex-end; 23 | font-size: 0.75rem; 24 | } 25 | 26 | code { 27 | background: rgba(148, 163, 184, 0.5); 28 | border-radius: 0.25rem; 29 | padding: 0.2rem 0.5rem; 30 | } 31 | -------------------------------------------------------------------------------- /update_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Usage: ./update_version.sh 3 | # FORMAT IS <0.0.0> 4 | 5 | if [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 6 | find . -name 'package.json' -not -path '*/node_modules/*' -exec bash -c ' 7 | # Parse the version from package.json 8 | current_version=$(grep -o "\"version\": \"[^\"]*" "$0" | cut -d"\"" -f4) 9 | 10 | # Update the version 11 | perl -i -pe"s/$current_version/'$1'/" "$0" 12 | ' {} \; 13 | 14 | echo "Updated versions to $1"; 15 | else 16 | echo "Version format <$1> isn't correct, proper format is <0.0.0>"; 17 | fi 18 | -------------------------------------------------------------------------------- /tests/e2e/specs/page-new-tab.test.ts: -------------------------------------------------------------------------------- 1 | import { canSwitchTheme } from '../helpers/theme'; 2 | 3 | describe('Webextension New Tab', () => { 4 | it('should open the extension page when a new tab is opened', async () => { 5 | const extensionPath = await browser.getExtensionPath(); 6 | const newTabUrl = process.env.__FIREFOX__ === 'true' ? `${extensionPath}/new-tab/index.html` : 'chrome://newtab'; 7 | 8 | await browser.url(newTabUrl); 9 | 10 | const appDiv = await $('.App').getElement(); 11 | await expect(appDiv).toBeExisting(); 12 | await canSwitchTheme(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /pages/side-panel/src/SidePanel.css: -------------------------------------------------------------------------------- 1 | .App { 2 | position: absolute; 3 | top: 0; 4 | bottom: 0; 5 | left: 0; 6 | right: 0; 7 | text-align: center; 8 | height: 100%; 9 | padding: 1rem; 10 | } 11 | 12 | .App-logo { 13 | height: 50vmin; 14 | margin-bottom: 1rem; 15 | } 16 | 17 | .App-header { 18 | height: 100%; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | } 25 | 26 | code { 27 | background: rgba(148, 163, 184, 0.5); 28 | border-radius: 0.25rem; 29 | padding: 0.2rem 0.5rem; 30 | } 31 | -------------------------------------------------------------------------------- /packages/hmr/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { BUILD_COMPLETE, DO_UPDATE, DONE_UPDATE } from './constant'; 2 | 3 | type UpdateRequestMessage = { 4 | type: typeof DO_UPDATE; 5 | id: string; 6 | }; 7 | 8 | type UpdateCompleteMessage = { type: typeof DONE_UPDATE }; 9 | type BuildCompletionMessage = { type: typeof BUILD_COMPLETE; id: string }; 10 | 11 | export type SerializedMessage = string; 12 | 13 | export type WebSocketMessage = UpdateCompleteMessage | UpdateRequestMessage | BuildCompletionMessage; 14 | 15 | export type PluginConfig = { 16 | onStart?: () => void; 17 | reload?: boolean; 18 | refresh?: boolean; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/ui/lib/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsWithoutRef } from 'react'; 2 | import { cn } from '../utils'; 3 | 4 | export type ButtonProps = { 5 | theme?: 'light' | 'dark'; 6 | } & ComponentPropsWithoutRef<'button'>; 7 | 8 | export function Button({ theme, className, children, ...props }: ButtonProps) { 9 | return ( 10 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/build-zip.yml: -------------------------------------------------------------------------------- 1 | name: Build And Upload Extension Zip Via Artifact 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: pnpm/action-setup@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version-file: '.nvmrc' 19 | cache: pnpm 20 | 21 | - run: pnpm install --frozen-lockfile --prefer-offline 22 | 23 | - run: pnpm build 24 | 25 | - uses: actions/upload-artifact@v4 26 | with: 27 | path: dist/* 28 | -------------------------------------------------------------------------------- /tests/e2e/specs/page-content.test.ts: -------------------------------------------------------------------------------- 1 | describe('Webextension Content Script', () => { 2 | it('should log "content script loaded" in console', async () => { 3 | await browser.sessionSubscribe({ events: ['log.entryAdded'] }); 4 | const logs: (string | null)[] = []; 5 | 6 | browser.on('log.entryAdded', logEntry => { 7 | logs.push(logEntry.text); 8 | }); 9 | 10 | await browser.url('https://www.example.com'); 11 | 12 | const EXPECTED_LOG_MESSAGE = 'content script loaded'; 13 | await browser.waitUntil(() => logs.includes(EXPECTED_LOG_MESSAGE)); 14 | 15 | expect(logs).toContain(EXPECTED_LOG_MESSAGE); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/hmr/rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import sucrase from '@rollup/plugin-sucrase'; 2 | 3 | const plugins = [ 4 | sucrase({ 5 | exclude: ['node_modules/**'], 6 | transforms: ['typescript'], 7 | }), 8 | ]; 9 | 10 | /** 11 | * @type {import("rollup").RollupOptions[]} 12 | */ 13 | export default [ 14 | { 15 | plugins, 16 | input: 'lib/injections/reload.ts', 17 | output: { 18 | format: 'iife', 19 | file: 'build/injections/reload.js', 20 | }, 21 | }, 22 | { 23 | plugins, 24 | input: 'lib/injections/refresh.ts', 25 | output: { 26 | format: 'iife', 27 | file: 'build/injections/refresh.js', 28 | }, 29 | }, 30 | ]; 31 | -------------------------------------------------------------------------------- /pages/content-runtime/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { withPageConfig } from '@extension/vite-config'; 3 | 4 | const rootDir = resolve(__dirname); 5 | const srcDir = resolve(rootDir, 'src'); 6 | 7 | export default withPageConfig({ 8 | resolve: { 9 | alias: { 10 | '@src': srcDir, 11 | }, 12 | }, 13 | publicDir: resolve(rootDir, 'public'), 14 | build: { 15 | lib: { 16 | formats: ['iife'], 17 | entry: resolve(__dirname, 'src/index.ts'), 18 | name: 'ContentRuntimeScript', 19 | fileName: 'index', 20 | }, 21 | outDir: resolve(rootDir, '..', '..', 'dist', 'content-runtime'), 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /packages/hmr/lib/initializers/initClient.ts: -------------------------------------------------------------------------------- 1 | import { DO_UPDATE, DONE_UPDATE, LOCAL_RELOAD_SOCKET_URL } from '../constant'; 2 | import MessageInterpreter from '../interpreter'; 3 | 4 | export default function initClient({ id, onUpdate }: { id: string; onUpdate: () => void }) { 5 | const ws = new WebSocket(LOCAL_RELOAD_SOCKET_URL); 6 | 7 | ws.onopen = () => { 8 | ws.addEventListener('message', event => { 9 | const message = MessageInterpreter.receive(String(event.data)); 10 | 11 | if (message.type === DO_UPDATE && message.id === id) { 12 | onUpdate(); 13 | ws.send(MessageInterpreter.send({ type: DONE_UPDATE })); 14 | return; 15 | } 16 | }); 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /packages/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Base", 4 | "compilerOptions": { 5 | "allowJs": true, 6 | "noEmit": true, 7 | "module": "esnext", 8 | "downlevelIteration": true, 9 | "isolatedModules": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "moduleResolution": "node", 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "skipLibCheck": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "resolveJsonModule": true, 19 | "noImplicitReturns": true, 20 | "jsx": "react-jsx", 21 | "lib": ["DOM", "ESNext"] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: Jonghakseo 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /pages/content-ui/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { makeEntryPointPlugin } from '@extension/hmr'; 3 | import { isDev, withPageConfig } from '@extension/vite-config'; 4 | 5 | const rootDir = resolve(__dirname); 6 | const srcDir = resolve(rootDir, 'src'); 7 | 8 | export default withPageConfig({ 9 | resolve: { 10 | alias: { 11 | '@src': srcDir, 12 | }, 13 | }, 14 | plugins: [isDev && makeEntryPointPlugin()], 15 | publicDir: resolve(rootDir, 'public'), 16 | build: { 17 | lib: { 18 | entry: resolve(srcDir, 'index.tsx'), 19 | name: 'contentUI', 20 | formats: ['iife'], 21 | fileName: 'index', 22 | }, 23 | outDir: resolve(rootDir, '..', '..', 'dist', 'content-ui'), 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /packages/i18n/locales/ko/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionDescription": { 3 | "description": "Extension description", 4 | "message": "React, Typescript, Vite를 사용한 크롬 익스텐션 보일러플레이트입니다." 5 | }, 6 | "extensionName": { 7 | "description": "Extension name", 8 | "message": "크롬 익스텐션 보일러플레이트" 9 | }, 10 | "toggleTheme": { 11 | "message": "테마 변경" 12 | }, 13 | "loading": { 14 | "message": "로딩 중..." 15 | }, 16 | "greeting": { 17 | "description": "인사 메시지", 18 | "message": "안녕하세요, 제 이름은 $NAME$입니다.", 19 | "placeholders": { 20 | "name": { 21 | "content": "$1", 22 | "example": "서종학" 23 | } 24 | } 25 | }, 26 | "hello": { 27 | "description": "Placeholder 예시", 28 | "message": "안녕 $1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/storage/lib/impl/exampleThemeStorage.ts: -------------------------------------------------------------------------------- 1 | import { StorageEnum } from '../base/enums'; 2 | import { createStorage } from '../base/base'; 3 | import type { BaseStorage } from '../base/types'; 4 | 5 | type Theme = 'light' | 'dark'; 6 | 7 | type ThemeStorage = BaseStorage & { 8 | toggle: () => Promise; 9 | }; 10 | 11 | const storage = createStorage('theme-storage-key', 'light', { 12 | storageEnum: StorageEnum.Local, 13 | liveUpdate: true, 14 | }); 15 | 16 | // You can extend it with your own methods 17 | export const exampleThemeStorage: ThemeStorage = { 18 | ...storage, 19 | toggle: async () => { 20 | await storage.set(currentTheme => { 21 | return currentTheme === 'light' ? 'dark' : 'light'; 22 | }); 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /pages/content/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { makeEntryPointPlugin } from '@extension/hmr'; 3 | import { isDev, withPageConfig } from '@extension/vite-config'; 4 | 5 | const rootDir = resolve(__dirname); 6 | const srcDir = resolve(rootDir, 'src'); 7 | 8 | export default withPageConfig({ 9 | resolve: { 10 | alias: { 11 | '@src': srcDir, 12 | }, 13 | }, 14 | publicDir: resolve(rootDir, 'public'), 15 | plugins: [isDev && makeEntryPointPlugin()], 16 | build: { 17 | lib: { 18 | entry: resolve(__dirname, 'src/index.ts'), 19 | formats: ['iife'], 20 | name: 'ContentScript', 21 | fileName: 'index', 22 | }, 23 | outDir: resolve(rootDir, '..', '..', 'dist', 'content'), 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /tests/e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/e2e", 3 | "version": "0.3.4", 4 | "description": "E2e tests configuration boilerplate", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "e2e": "wdio run ./config/wdio.browser.conf.ts", 9 | "clean:node_modules": "pnpx rimraf node_modules", 10 | "clean:turbo": "pnpx rimraf .turbo", 11 | "clean": "pnpm clean:turbo && pnpm clean:node_modules" 12 | }, 13 | "devDependencies": { 14 | "@extension/tsconfig": "workspace:*", 15 | "@wdio/cli": "^9.1.2", 16 | "@wdio/globals": "^9.1.2", 17 | "@wdio/local-runner": "^9.1.2", 18 | "@wdio/mocha-framework": "^9.1.2", 19 | "@wdio/spec-reporter": "^9.1.2", 20 | "@wdio/types": "^9.1.2", 21 | "tsx": "^4.19.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | > `*` Please fill in the required items. 4 | 5 | ## Priority* 6 | 7 | - [ ] High: This PR needs to be merged first for other tasks. 8 | - [x] Middle: This PR should be merged quickly to prevent conflicts due to common changes. (default) 9 | - [ ] Low: This PR does not affect other tasks, so it can be merged later. 10 | 11 | ## Purpose of the PR* 12 | 13 | 14 | ## Changes* 15 | 16 | 17 | ## How to check the feature 18 | 19 | 20 | 21 | 22 | ## Reference 23 | 24 | -------------------------------------------------------------------------------- /packages/i18n/locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionDescription": { 3 | "description": "Extension description", 4 | "message": "Chrome extension boilerplate developed with Vite, React and Typescript" 5 | }, 6 | "extensionName": { 7 | "description": "Extension name", 8 | "message": "Chrome extension boilerplate" 9 | }, 10 | "toggleTheme": { 11 | "message": "Toggle theme" 12 | }, 13 | "loading": { 14 | "message": "Loading..." 15 | }, 16 | "greeting": { 17 | "description": "Greeting message", 18 | "message": "Hello, My name is $NAME$", 19 | "placeholders": { 20 | "name": { 21 | "content": "$1", 22 | "example": "John Doe" 23 | } 24 | } 25 | }, 26 | "hello": { 27 | "description": "Placeholder example", 28 | "message": "Hello $1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pages/content-ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { Button } from '@extension/ui'; 3 | import { useStorage } from '@extension/shared'; 4 | import { exampleThemeStorage } from '@extension/storage'; 5 | 6 | export default function App() { 7 | const theme = useStorage(exampleThemeStorage); 8 | 9 | useEffect(() => { 10 | console.log('content ui loaded'); 11 | }, []); 12 | 13 | return ( 14 |
15 |
16 | Edit pages/content-ui/src/app.tsx and save to reload. 17 |
18 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /packages/i18n/build.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import esbuild from 'esbuild'; 4 | import { rimraf } from 'rimraf'; 5 | 6 | /** 7 | * @param i18nPath {string} 8 | */ 9 | export async function build(i18nPath) { 10 | fs.cpSync(i18nPath, path.resolve('lib', 'i18n.ts')); 11 | 12 | await esbuild.build({ 13 | entryPoints: ['./index.ts'], 14 | tsconfig: './tsconfig.json', 15 | bundle: true, 16 | packages: 'bundle', 17 | target: 'es6', 18 | outdir: './dist', 19 | sourcemap: true, 20 | format: 'esm', 21 | }); 22 | 23 | const outDir = path.resolve('..', '..', 'dist'); 24 | const localePath = path.resolve(outDir, '_locales'); 25 | rimraf.sync(localePath); 26 | fs.cpSync(path.resolve('locales'), localePath, { recursive: true }); 27 | 28 | console.log('I18n build complete'); 29 | } 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: Jonghakseo 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. Mac, Window, Linux] 28 | - Browser [e.g. chrome, firefox] 29 | - Node Version [e.g. 18.19.1] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /packages/storage/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/storage", 3 | "version": "0.3.4", 4 | "description": "chrome extension - storage", 5 | "private": true, 6 | "sideEffects": false, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "main": "./dist/index.js", 11 | "types": "index.ts", 12 | "scripts": { 13 | "clean:bundle": "rimraf dist", 14 | "clean:node_modules": "pnpx rimraf node_modules", 15 | "clean:turbo": "rimraf .turbo", 16 | "clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo", 17 | "ready": "node build.mjs", 18 | "lint": "eslint . --ext .ts,.tsx", 19 | "lint:fix": "pnpm lint --fix", 20 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 21 | "type-check": "tsc --noEmit" 22 | }, 23 | "devDependencies": { 24 | "@extension/tsconfig": "workspace:*" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/hmr/lib/injections/refresh.ts: -------------------------------------------------------------------------------- 1 | import initClient from '../initializers/initClient'; 2 | 3 | function addRefresh() { 4 | let pendingReload = false; 5 | 6 | initClient({ 7 | // @ts-expect-error That's because of the dynamic code loading 8 | id: __HMR_ID, 9 | onUpdate: () => { 10 | // disable reload when tab is hidden 11 | if (document.hidden) { 12 | pendingReload = true; 13 | return; 14 | } 15 | reload(); 16 | }, 17 | }); 18 | 19 | // reload 20 | function reload(): void { 21 | pendingReload = false; 22 | window.location.reload(); 23 | } 24 | 25 | // reload when tab is visible 26 | function reloadWhenTabIsVisible(): void { 27 | !document.hidden && pendingReload && reload(); 28 | } 29 | 30 | document.addEventListener('visibilitychange', reloadWhenTabIsVisible); 31 | } 32 | 33 | addRefresh(); 34 | -------------------------------------------------------------------------------- /.github/auto_assign.yml: -------------------------------------------------------------------------------- 1 | # Set to true to add reviewers to pull requests 2 | addReviewers: true 3 | 4 | # Set to true to add assignees to pull requests 5 | addAssignees: author 6 | 7 | # A list of reviewers to be added to pull requests (GitHub user name) 8 | reviewers: 9 | - Jonghakseo 10 | 11 | # A number of reviewers added to the pull request 12 | # Set 0 to add all the reviewers (default: 0) 13 | numberOfReviewers: 0 14 | 15 | # A list of assignees, overrides reviewers if set 16 | # assignees: 17 | # - assigneeA 18 | 19 | # A number of assignees to add to the pull request 20 | # Set to 0 to add all of the assignees. 21 | # Uses numberOfReviewers if unset. 22 | # numberOfAssignees: 2 23 | 24 | # A list of keywords to be skipped the process that add reviewers if pull requests include it 25 | # skipKeywords: 26 | # - wip 27 | 28 | filterLabels: 29 | exclude: 30 | - dependencies 31 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/shared", 3 | "version": "0.3.4", 4 | "description": "chrome extension - shared code", 5 | "private": true, 6 | "sideEffects": false, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "types": "index.ts", 11 | "main": "./dist/index.js", 12 | "scripts": { 13 | "clean:bundle": "rimraf dist", 14 | "clean:node_modules": "pnpx rimraf node_modules", 15 | "clean:turbo": "rimraf .turbo", 16 | "clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo", 17 | "ready": "node build.mjs", 18 | "lint": "eslint . --ext .ts,.tsx", 19 | "lint:fix": "pnpm lint --fix", 20 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 21 | "type-check": "tsc --noEmit" 22 | }, 23 | "devDependencies": { 24 | "@extension/storage": "workspace:*", 25 | "@extension/tsconfig": "workspace:*" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/dev-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/dev-utils", 3 | "version": "0.3.4", 4 | "description": "chrome extension - dev utils", 5 | "private": true, 6 | "sideEffects": false, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "main": "dist/index.js", 11 | "module": "dist/index.js", 12 | "types": "index.ts", 13 | "scripts": { 14 | "clean:bundle": "rimraf dist", 15 | "clean:node_modules": "pnpx rimraf node_modules", 16 | "clean:turbo": "rimraf .turbo", 17 | "clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo", 18 | "ready": "tsc", 19 | "lint": "eslint . --ext .ts,.tsx", 20 | "lint:fix": "pnpm lint --fix", 21 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 22 | "type-check": "tsc --noEmit" 23 | }, 24 | "devDependencies": { 25 | "@extension/tsconfig": "workspace:*", 26 | "@extension/shared": "workspace:*" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pages/content-runtime/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/content-runtime-script", 3 | "version": "0.3.4", 4 | "description": "chrome extension - content runtime script", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "clean:node_modules": "pnpx rimraf node_modules", 12 | "clean:turbo": "rimraf .turbo", 13 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 14 | "build": "vite build", 15 | "dev": "cross-env __DEV__=true vite build --mode development", 16 | "lint": "eslint . --ext .ts,.tsx", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "devDependencies": { 22 | "@extension/tsconfig": "workspace:*", 23 | "@extension/hmr": "workspace:*", 24 | "@extension/vite-config": "workspace:*" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | name: Formating validation 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | prettier: 10 | name: Prettier Check 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Repository 14 | uses: actions/checkout@v2 15 | 16 | - name: Run Prettier 17 | id: prettier-run 18 | uses: rutajdash/prettier-cli-action@v1.0.0 19 | with: 20 | config_path: ./.prettierrc 21 | file_pattern: "*.{js,jsx,ts,tsx,json}" 22 | 23 | # This step only runs if prettier finds errors causing the previous step to fail 24 | # This steps lists the files where errors were found 25 | - name: Prettier Output 26 | if: ${{ failure() }} 27 | shell: bash 28 | run: | 29 | echo "The following files aren't formatted properly:" 30 | echo "${{steps.prettier-run.outputs.prettier_output}}" -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: Run E2E Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | chrome: 11 | name: E2E tests for Chrome 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: pnpm/action-setup@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version-file: '.nvmrc' 19 | cache: pnpm 20 | - run: pnpm install --frozen-lockfile --prefer-offline 21 | - run: pnpm e2e 22 | 23 | firefox: 24 | name: E2E tests for Firefox 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: pnpm/action-setup@v4 29 | - uses: actions/setup-node@v4 30 | with: 31 | node-version-file: '.nvmrc' 32 | cache: pnpm 33 | - run: pnpm install --frozen-lockfile --prefer-offline 34 | - run: pnpm e2e:firefox 35 | -------------------------------------------------------------------------------- /pages/devtools/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/devtools", 3 | "version": "0.3.4", 4 | "description": "chrome extension - devtools", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "clean:node_modules": "pnpx rimraf node_modules", 12 | "clean:turbo": "rimraf .turbo", 13 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 14 | "build": "vite build", 15 | "dev": "cross-env __DEV__=true vite build --mode development", 16 | "lint": "eslint . --ext .ts,.tsx", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@extension/shared": "workspace:*" 23 | }, 24 | "devDependencies": { 25 | "@extension/tsconfig": "workspace:*", 26 | "@extension/vite-config": "workspace:*", 27 | "cross-env": "^7.0.3" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/ui/build.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { replaceTscAliasPaths } from 'tsc-alias'; 3 | import { resolve } from 'node:path'; 4 | import esbuild from 'esbuild'; 5 | 6 | /** 7 | * @type { import('esbuild').BuildOptions } 8 | */ 9 | const buildOptions = { 10 | entryPoints: ['./index.ts', './lib/**/*.ts', './lib/**/*.tsx'], 11 | tsconfig: './tsconfig.json', 12 | bundle: false, 13 | target: 'es6', 14 | outdir: './dist', 15 | sourcemap: true, 16 | }; 17 | 18 | await esbuild.build(buildOptions); 19 | 20 | /** 21 | * Post build paths resolve since ESBuild only natively 22 | * support paths resolution for bundling scenario 23 | * @url https://github.com/evanw/esbuild/issues/394#issuecomment-1537247216 24 | */ 25 | await replaceTscAliasPaths({ 26 | configFile: 'tsconfig.json', 27 | watch: false, 28 | outDir: 'dist', 29 | declarationDir: 'dist', 30 | }); 31 | 32 | fs.copyFileSync(resolve('lib', 'global.css'), resolve('dist', 'global.css')); 33 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an Issue or Pull Request becomes stale 2 | daysUntilStale: 90 3 | # Number of days of inactivity before a stale Issue or Pull Request is closed 4 | daysUntilClose: 30 5 | # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking as stale 10 | staleLabel: stale 11 | # Comment to post when marking as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when removing the stale label. Set to `false` to disable 17 | unmarkComment: false 18 | # Comment to post when closing a stale Issue or Pull Request. Set to `false` to disable 19 | closeComment: true 20 | # Limit to only `issues` or `pulls` 21 | only: issues 22 | -------------------------------------------------------------------------------- /packages/zipper/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/zipper", 3 | "version": "0.3.4", 4 | "description": "chrome extension - zipper", 5 | "private": true, 6 | "sideEffects": false, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "main": "dist/index.js", 11 | "module": "dist/index.js", 12 | "types": "index.ts", 13 | "scripts": { 14 | "clean:bundle": "rimraf dist", 15 | "clean:node_modules": "pnpx rimraf node_modules", 16 | "clean:turbo": "rimraf .turbo", 17 | "clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo", 18 | "zip": "tsx index.ts", 19 | "ready": "tsc", 20 | "lint": "eslint . --ext .ts,.tsx", 21 | "lint:fix": "pnpm lint --fix", 22 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 23 | "type-check": "tsc --noEmit" 24 | }, 25 | "devDependencies": { 26 | "@extension/tsconfig": "workspace:*", 27 | "fast-glob": "^3.3.2", 28 | "fflate": "^0.8.2", 29 | "tsx": "^4.19.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/i18n/lib/getMessageFromLocale.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is generated by generate-i18n.mjs 3 | * Do not edit this file directly 4 | */ 5 | import enMessage from '../locales/en/messages.json'; 6 | import koMessage from '../locales/ko/messages.json'; 7 | 8 | export function getMessageFromLocale(locale: string) { 9 | switch (locale) { 10 | case 'en': 11 | return enMessage; 12 | case 'ko': 13 | return koMessage; 14 | default: 15 | throw new Error('Unsupported locale'); 16 | } 17 | } 18 | 19 | export const defaultLocale = (() => { 20 | const locales = ['en', 'ko']; 21 | const firstLocale = locales[0]; 22 | const defaultLocale = Intl.DateTimeFormat().resolvedOptions().locale.replace('-', '_'); 23 | if (locales.includes(defaultLocale)) { 24 | return defaultLocale; 25 | } 26 | const defaultLocaleWithoutRegion = defaultLocale.split('_')[0]; 27 | if (locales.includes(defaultLocaleWithoutRegion)) { 28 | return defaultLocaleWithoutRegion; 29 | } 30 | return firstLocale; 31 | })(); 32 | -------------------------------------------------------------------------------- /packages/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/ui", 3 | "version": "0.3.4", 4 | "description": "chrome extension - ui components", 5 | "private": true, 6 | "sideEffects": false, 7 | "type": "module", 8 | "files": [ 9 | "dist/**", 10 | "dist/global.css" 11 | ], 12 | "types": "index.ts", 13 | "main": "./dist/index.js", 14 | "scripts": { 15 | "clean:bundle": "rimraf dist", 16 | "clean:node_modules": "pnpx rimraf node_modules", 17 | "clean:turbo": "rimraf .turbo", 18 | "clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo", 19 | "ready": "node build.mjs", 20 | "lint": "eslint . --ext .ts,.tsx", 21 | "lint:fix": "pnpm lint --fix", 22 | "prettier": "prettier . --write", 23 | "type-check": "tsc --noEmit" 24 | }, 25 | "devDependencies": { 26 | "@extension/tsconfig": "workspace:*", 27 | "deepmerge": "^4.3.1", 28 | "tsc-alias": "^1.8.10" 29 | }, 30 | "dependencies": { 31 | "clsx": "^2.1.1", 32 | "tailwind-merge": "^2.4.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/i18n/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/i18n", 3 | "version": "0.3.4", 4 | "description": "chrome extension - internationalization", 5 | "private": true, 6 | "sideEffects": false, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "types": "index.ts", 11 | "main": "./dist/index.js", 12 | "scripts": { 13 | "clean:bundle": "rimraf dist", 14 | "clean:node_modules": "pnpx rimraf node_modules", 15 | "clean:turbo": "rimraf .turbo", 16 | "clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo", 17 | "genenrate-i8n": "node genenrate-i18n.mjs", 18 | "ready": "pnpm genenrate-i8n && node build.dev.mjs", 19 | "build": "pnpm genenrate-i8n && node build.prod.mjs", 20 | "lint": "eslint . --ext .ts,.tsx", 21 | "lint:fix": "pnpm lint --fix", 22 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 23 | "type-check": "tsc --noEmit" 24 | }, 25 | "devDependencies": { 26 | "@extension/tsconfig": "workspace:*", 27 | "@extension/hmr": "workspace:*" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pages/content/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/content-script", 3 | "version": "0.3.4", 4 | "description": "chrome extension - content script", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "clean:node_modules": "pnpx rimraf node_modules", 12 | "clean:turbo": "rimraf .turbo", 13 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 14 | "build": "vite build", 15 | "dev": "cross-env __DEV__=true vite build --mode development", 16 | "lint": "eslint . --ext .ts,.tsx", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@extension/shared": "workspace:*", 23 | "@extension/storage": "workspace:*" 24 | }, 25 | "devDependencies": { 26 | "@extension/hmr": "workspace:*", 27 | "@extension/tsconfig": "workspace:*", 28 | "@extension/vite-config": "workspace:*", 29 | "cross-env": "^7.0.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true, 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:react-hooks/recommended", 12 | "plugin:import/recommended", 13 | "plugin:jsx-a11y/recommended", 14 | "plugin:tailwindcss/recommended", 15 | "prettier", 16 | ], 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "ecmaFeatures": { 20 | "jsx": true, 21 | }, 22 | "ecmaVersion": "latest", 23 | "sourceType": "module", 24 | }, 25 | "plugins": ["react", "@typescript-eslint", "react-hooks", "import", "jsx-a11y", "prettier"], 26 | "settings": { 27 | "react": { 28 | "version": "detect", 29 | }, 30 | }, 31 | "rules": { 32 | "react/react-in-jsx-scope": "off", 33 | "import/no-unresolved": "off", 34 | "@typescript-eslint/consistent-type-imports": "error", 35 | }, 36 | "globals": { 37 | "chrome": "readonly", 38 | }, 39 | "ignorePatterns": ["watch.js", "dist/**"], 40 | } 41 | -------------------------------------------------------------------------------- /packages/shared/lib/hoc/withErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentType, ErrorInfo, ReactElement } from 'react'; 2 | import { Component } from 'react'; 3 | 4 | class ErrorBoundary extends Component< 5 | { 6 | children: ReactElement; 7 | fallback: ReactElement; 8 | }, 9 | { 10 | hasError: boolean; 11 | } 12 | > { 13 | state = { hasError: false }; 14 | 15 | static getDerivedStateFromError() { 16 | return { hasError: true }; 17 | } 18 | 19 | componentDidCatch(error: Error, errorInfo: ErrorInfo) { 20 | console.error(error, errorInfo); 21 | } 22 | 23 | render() { 24 | if (this.state.hasError) { 25 | return this.props.fallback; 26 | } 27 | 28 | return this.props.children; 29 | } 30 | } 31 | 32 | export function withErrorBoundary>( 33 | Component: ComponentType, 34 | ErrorComponent: ReactElement, 35 | ) { 36 | return function WithErrorBoundary(props: T) { 37 | return ( 38 | 39 | 40 | 41 | ); 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /tests/e2e/helpers/theme.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper method to check if user can click on theme button and toggle theme color 3 | */ 4 | export const canSwitchTheme = async () => { 5 | const LIGHT_THEME_CLASS = 'bg-slate-50'; 6 | const DARK_THEME_CLASS = 'bg-gray-800'; 7 | const TOGGLE_BUTTON_TEXT = 'Toggle theme'; 8 | 9 | const app = await $('.App').getElement(); 10 | const toggleThemeButton = await $(`button=${TOGGLE_BUTTON_TEXT}`).getElement(); 11 | 12 | await expect(app).toBeExisting(); 13 | await expect(toggleThemeButton).toBeExisting(); 14 | 15 | const appClasses = await app.getAttribute('class'); 16 | const initialThemeClass = appClasses.includes(LIGHT_THEME_CLASS) ? LIGHT_THEME_CLASS : DARK_THEME_CLASS; 17 | const afterClickThemeClass = appClasses.includes(LIGHT_THEME_CLASS) ? DARK_THEME_CLASS : LIGHT_THEME_CLASS; 18 | 19 | // Toggle theme 20 | await toggleThemeButton.click(); 21 | await expect(app).toHaveElementClass(afterClickThemeClass); 22 | 23 | // Toggle back to initial theme 24 | await toggleThemeButton.click(); 25 | await expect(app).toHaveElementClass(initialThemeClass); 26 | }; 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Seo Jong Hak 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 | -------------------------------------------------------------------------------- /packages/dev-utils/lib/manifest-parser/impl.ts: -------------------------------------------------------------------------------- 1 | import type { ManifestParserInterface, Manifest } from './type'; 2 | 3 | export const ManifestParserImpl: ManifestParserInterface = { 4 | convertManifestToString: (manifest, env) => { 5 | if (env === 'firefox') { 6 | manifest = convertToFirefoxCompatibleManifest(manifest); 7 | } 8 | return JSON.stringify(manifest, null, 2); 9 | }, 10 | }; 11 | 12 | function convertToFirefoxCompatibleManifest(manifest: Manifest) { 13 | const manifestCopy = { 14 | ...manifest, 15 | } as { [key: string]: unknown }; 16 | 17 | manifestCopy.background = { 18 | scripts: [manifest.background?.service_worker], 19 | type: 'module', 20 | }; 21 | manifestCopy.options_ui = { 22 | page: manifest.options_page, 23 | browser_style: false, 24 | }; 25 | manifestCopy.content_security_policy = { 26 | extension_pages: "script-src 'self'; object-src 'self'", 27 | }; 28 | manifestCopy.browser_specific_settings = { 29 | gecko: { 30 | id: 'example@example.com', 31 | strict_min_version: '109.0', 32 | }, 33 | }; 34 | delete manifestCopy.options_page; 35 | return manifestCopy as Manifest; 36 | } 37 | -------------------------------------------------------------------------------- /tests/e2e/specs/page-content-runtime.test.ts: -------------------------------------------------------------------------------- 1 | describe('Webextension Content Runtime Script', () => { 2 | before(function () { 3 | if ((browser.capabilities as WebdriverIO.Capabilities).browserName === 'chrome') { 4 | // Chrome doesn't allow content scripts on the extension pages 5 | this.skip(); 6 | } 7 | }); 8 | 9 | it('should create runtime element on the page', async () => { 10 | // Open the popup 11 | const extensionPath = await browser.getExtensionPath(); 12 | const popupUrl = `${extensionPath}/popup/index.html`; 13 | await browser.url(popupUrl); 14 | 15 | await expect(browser).toHaveTitle('Popup'); 16 | 17 | // Trigger the content script on the popup 18 | // button contains "Content Script" text 19 | const contentScriptButton = await $('button*=Content Script').getElement(); 20 | 21 | await contentScriptButton.click(); 22 | 23 | // Check if id chrome-extension-boilerplate-react-vite-runtime-content-view-root exists on page 24 | const runtimeElement = await $('#chrome-extension-boilerplate-react-vite-runtime-content-view-root').getElement(); 25 | 26 | await expect(runtimeElement).toBeExisting(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /pages/side-panel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/sidepanel", 3 | "version": "0.3.4", 4 | "description": "chrome extension - side panel", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "clean:node_modules": "pnpx rimraf node_modules", 12 | "clean:turbo": "rimraf .turbo", 13 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 14 | "build": "vite build", 15 | "dev": "cross-env __DEV__=true vite build --mode development", 16 | "lint": "eslint . --ext .ts,.tsx", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@extension/shared": "workspace:*", 23 | "@extension/storage": "workspace:*" 24 | }, 25 | "devDependencies": { 26 | "@extension/tailwindcss-config": "workspace:*", 27 | "@extension/tsconfig": "workspace:*", 28 | "@extension/vite-config": "workspace:*", 29 | "postcss-load-config": "^6.0.1", 30 | "cross-env": "^7.0.3" 31 | }, 32 | "postcss": { 33 | "plugins": { 34 | "tailwindcss": {}, 35 | "autoprefixer": {} 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pages/options/src/Options.tsx: -------------------------------------------------------------------------------- 1 | import '@src/Options.css'; 2 | import { useStorage, withErrorBoundary, withSuspense } from '@extension/shared'; 3 | import { exampleThemeStorage } from '@extension/storage'; 4 | import { Button } from '@extension/ui'; 5 | 6 | const Options = () => { 7 | const theme = useStorage(exampleThemeStorage); 8 | const isLight = theme === 'light'; 9 | const logo = isLight ? 'options/logo_horizontal.svg' : 'options/logo_horizontal_dark.svg'; 10 | const goGithubSite = () => 11 | chrome.tabs.create({ url: 'https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite' }); 12 | 13 | return ( 14 |
15 | 18 |

19 | Edit pages/options/src/Options.tsx 20 |

21 | 24 |
25 | ); 26 | }; 27 | 28 | export default withErrorBoundary(withSuspense(Options,
Loading ...
),
Error Occur
); 29 | -------------------------------------------------------------------------------- /pages/devtools-panel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/devtools-panel", 3 | "version": "0.3.4", 4 | "description": "chrome extension - devtools panel", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "clean:node_modules": "pnpx rimraf node_modules", 12 | "clean:turbo": "rimraf .turbo", 13 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 14 | "build": "vite build", 15 | "dev": "cross-env __DEV__=true vite build --mode development", 16 | "lint": "eslint . --ext .ts,.tsx", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@extension/shared": "workspace:*", 23 | "@extension/storage": "workspace:*" 24 | }, 25 | "devDependencies": { 26 | "@extension/tailwindcss-config": "workspace:*", 27 | "@extension/tsconfig": "workspace:*", 28 | "@extension/vite-config": "workspace:*", 29 | "postcss-load-config": "^6.0.1", 30 | "cross-env": "^7.0.3" 31 | }, 32 | "postcss": { 33 | "plugins": { 34 | "tailwindcss": {}, 35 | "autoprefixer": {} 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pages/options/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/options", 3 | "version": "0.3.4", 4 | "description": "chrome extension - options", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "clean:node_modules": "pnpx rimraf node_modules", 12 | "clean:turbo": "rimraf .turbo", 13 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 14 | "build": "vite build", 15 | "dev": "cross-env __DEV__=true vite build --mode development", 16 | "lint": "eslint . --ext .ts,.tsx", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@extension/shared": "workspace:*", 23 | "@extension/storage": "workspace:*", 24 | "@extension/ui": "workspace:*" 25 | }, 26 | "devDependencies": { 27 | "@extension/tailwindcss-config": "workspace:*", 28 | "@extension/tsconfig": "workspace:*", 29 | "@extension/vite-config": "workspace:*", 30 | "postcss-load-config": "^6.0.1", 31 | "cross-env": "^7.0.3" 32 | }, 33 | "postcss": { 34 | "plugins": { 35 | "tailwindcss": {}, 36 | "autoprefixer": {} 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pages/popup/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/popup", 3 | "version": "0.3.4", 4 | "description": "chrome extension - popup", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "clean:node_modules": "pnpx rimraf node_modules", 12 | "clean:turbo": "rimraf .turbo", 13 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 14 | "build": "vite build", 15 | "dev": "cross-env __DEV__=true vite build --mode development", 16 | "lint": "eslint . --ext .ts,.tsx", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@extension/shared": "workspace:*", 23 | "@extension/storage": "workspace:*", 24 | "@extension/content-runtime-script": "workspace:*" 25 | }, 26 | "devDependencies": { 27 | "@extension/tailwindcss-config": "workspace:*", 28 | "@extension/tsconfig": "workspace:*", 29 | "@extension/vite-config": "workspace:*", 30 | "postcss-load-config": "^6.0.1", 31 | "cross-env": "^7.0.3" 32 | }, 33 | "postcss": { 34 | "plugins": { 35 | "tailwindcss": {}, 36 | "autoprefixer": {} 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /chrome-extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-extension", 3 | "version": "0.3.4", 4 | "description": "chrome extension - core settings", 5 | "type": "module", 6 | "scripts": { 7 | "clean:node_modules": "pnpx rimraf node_modules", 8 | "clean:turbo": "rimraf .turbo", 9 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 10 | "build": "vite build", 11 | "dev": "cross-env __DEV__=true vite build --mode development", 12 | "test": "vitest run", 13 | "lint": "eslint ./ --ext .ts,.js,.tsx,.jsx", 14 | "lint:fix": "pnpm lint --fix", 15 | "prettier": "prettier . --write --ignore-path ../.prettierignore", 16 | "type-check": "tsc --noEmit" 17 | }, 18 | "dependencies": { 19 | "webextension-polyfill": "^0.12.0", 20 | "@extension/shared": "workspace:*", 21 | "@extension/storage": "workspace:*" 22 | }, 23 | "devDependencies": { 24 | "@extension/dev-utils": "workspace:*", 25 | "@extension/hmr": "workspace:*", 26 | "@extension/tsconfig": "workspace:*", 27 | "@extension/vite-config": "workspace:*", 28 | "@laynezh/vite-plugin-lib-assets": "^0.5.23", 29 | "@types/ws": "^8.5.12", 30 | "magic-string": "^0.30.10", 31 | "ts-loader": "^9.5.1", 32 | "deepmerge": "^4.3.1", 33 | "cross-env": "^7.0.3" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "ui": "tui", 4 | "globalEnv": ["__FIREFOX__"], 5 | "tasks": { 6 | "ready": { 7 | "dependsOn": ["^ready"], 8 | "outputs": ["dist/**", "build/**"] 9 | }, 10 | "dev": { 11 | "dependsOn": ["ready"], 12 | "outputs": ["dist/**", "build/**", "i18n/locales/**"], 13 | "cache": false, 14 | "persistent": true 15 | }, 16 | "build": { 17 | "dependsOn": ["^build"], 18 | "outputs": ["../../dist/**", "dist/**", "build/**"], 19 | "cache": false 20 | }, 21 | "e2e": { 22 | "cache": false 23 | }, 24 | "type-check": { 25 | "cache": false 26 | }, 27 | "lint": { 28 | "cache": false 29 | }, 30 | "lint:fix": { 31 | "cache": false 32 | }, 33 | "prettier": { 34 | "cache": false 35 | }, 36 | "clean:node_modules": { 37 | "dependsOn": ["^clean:node_modules"], 38 | "cache": false 39 | }, 40 | "clean:turbo": { 41 | "dependsOn": ["^clean:turbo"], 42 | "cache": false 43 | }, 44 | "clean:bundle": { 45 | "dependsOn": ["^clean:bundle"], 46 | "cache": false 47 | }, 48 | "clean": { 49 | "dependsOn": ["^clean"], 50 | "cache": false 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pages/content-ui/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import App from '@src/App'; 3 | import tailwindcssOutput from '../dist/tailwind-output.css?inline'; 4 | 5 | const root = document.createElement('div'); 6 | root.id = 'chrome-extension-boilerplate-react-vite-content-view-root'; 7 | 8 | document.body.append(root); 9 | 10 | const rootIntoShadow = document.createElement('div'); 11 | rootIntoShadow.id = 'shadow-root'; 12 | 13 | const shadowRoot = root.attachShadow({ mode: 'open' }); 14 | 15 | if (navigator.userAgent.includes('Firefox')) { 16 | /** 17 | * In the firefox environment, adoptedStyleSheets cannot be used due to the bug 18 | * @url https://bugzilla.mozilla.org/show_bug.cgi?id=1770592 19 | * 20 | * Injecting styles into the document, this may cause style conflicts with the host page 21 | */ 22 | const styleElement = document.createElement('style'); 23 | styleElement.innerHTML = tailwindcssOutput; 24 | shadowRoot.appendChild(styleElement); 25 | } else { 26 | /** Inject styles into shadow dom */ 27 | const globalStyleSheet = new CSSStyleSheet(); 28 | globalStyleSheet.replaceSync(tailwindcssOutput); 29 | shadowRoot.adoptedStyleSheets = [globalStyleSheet]; 30 | } 31 | 32 | shadowRoot.appendChild(rootIntoShadow); 33 | createRoot(rootIntoShadow).render(); 34 | -------------------------------------------------------------------------------- /packages/vite-config/lib/withPageConfig.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { watchRebuildPlugin } from '@extension/hmr'; 3 | import react from '@vitejs/plugin-react-swc'; 4 | import deepmerge from 'deepmerge'; 5 | import { isDev, isProduction } from './env.mjs'; 6 | 7 | export const watchOption = isDev ? { 8 | buildDelay: 100, 9 | chokidar: { 10 | ignored:[ 11 | /\/packages\/.*\.(ts|tsx|map)$/, 12 | ] 13 | } 14 | }: undefined; 15 | 16 | /** 17 | * @typedef {import('vite').UserConfig} UserConfig 18 | * @param {UserConfig} config 19 | * @returns {UserConfig} 20 | */ 21 | export function withPageConfig(config) { 22 | return defineConfig( 23 | deepmerge( 24 | { 25 | base: '', 26 | plugins: [react(), isDev && watchRebuildPlugin({ refresh: true })], 27 | build: { 28 | sourcemap: isDev, 29 | minify: isProduction, 30 | reportCompressedSize: isProduction, 31 | emptyOutDir: isProduction, 32 | watch: watchOption, 33 | rollupOptions: { 34 | external: ['chrome'], 35 | }, 36 | }, 37 | define: { 38 | 'process.env.NODE_ENV': isDev ? `"development"` : `"production"`, 39 | }, 40 | envDir: '../..' 41 | }, 42 | config, 43 | ), 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /pages/new-tab/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/new-tab", 3 | "version": "0.3.4", 4 | "description": "chrome extension - new tab", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "clean:node_modules": "pnpx rimraf node_modules", 12 | "clean:turbo": "rimraf .turbo", 13 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 14 | "build": "vite build", 15 | "dev": "cross-env __DEV__=true vite build --mode development", 16 | "lint": "eslint . --ext .ts,.tsx", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@extension/shared": "workspace:*", 23 | "@extension/storage": "workspace:*", 24 | "@extension/ui": "workspace:*", 25 | "@extension/i18n": "workspace:*" 26 | }, 27 | "devDependencies": { 28 | "@extension/tailwindcss-config": "workspace:*", 29 | "@extension/tsconfig": "workspace:*", 30 | "@extension/vite-config": "workspace:*", 31 | "sass": "1.79.4", 32 | "postcss-load-config": "^6.0.1", 33 | "cross-env": "^7.0.3" 34 | }, 35 | "postcss": { 36 | "plugins": { 37 | "tailwindcss": {}, 38 | "autoprefixer": {} 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/hmr/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/hmr", 3 | "version": "0.3.4", 4 | "description": "chrome extension - hot module reload/refresh", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "main": "dist/index.js", 11 | "module": "dist/index.js", 12 | "types": "index.ts", 13 | "scripts": { 14 | "clean:bundle": "rimraf dist && pnpx rimraf build", 15 | "clean:node_modules": "pnpx rimraf node_modules", 16 | "clean:turbo": "rimraf .turbo", 17 | "clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo", 18 | "build:tsc": "tsc -b tsconfig.build.json", 19 | "build:rollup": "rollup --config rollup.config.mjs", 20 | "ready": "pnpm run build:tsc && pnpm run build:rollup", 21 | "dev": "node dist/lib/initializers/initReloadServer.js", 22 | "lint": "eslint . --ext .ts,.tsx", 23 | "lint:fix": "pnpm lint --fix", 24 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 25 | "type-check": "tsc --noEmit" 26 | }, 27 | "devDependencies": { 28 | "@extension/tsconfig": "workspace:*", 29 | "@rollup/plugin-sucrase": "^5.0.2", 30 | "@types/ws": "^8.5.12", 31 | "esm": "^3.2.25", 32 | "fast-glob": "^3.3.2", 33 | "rollup": "^4.24.0", 34 | "ts-node": "^10.9.2", 35 | "ws": "8.18.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/dev-utils/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import type { ValueOf } from '@extension/shared'; 2 | 3 | type ColorType = 'success' | 'info' | 'error' | 'warning' | keyof typeof COLORS; 4 | 5 | export function colorLog(message: string, type: ColorType) { 6 | let color: ValueOf; 7 | 8 | switch (type) { 9 | case 'success': 10 | color = COLORS.FgGreen; 11 | break; 12 | case 'info': 13 | color = COLORS.FgBlue; 14 | break; 15 | case 'error': 16 | color = COLORS.FgRed; 17 | break; 18 | case 'warning': 19 | color = COLORS.FgYellow; 20 | break; 21 | default: 22 | color = COLORS[type]; 23 | break; 24 | } 25 | 26 | console.log(color, message); 27 | } 28 | 29 | const COLORS = { 30 | Reset: '\x1b[0m', 31 | Bright: '\x1b[1m', 32 | Dim: '\x1b[2m', 33 | Underscore: '\x1b[4m', 34 | Blink: '\x1b[5m', 35 | Reverse: '\x1b[7m', 36 | Hidden: '\x1b[8m', 37 | FgBlack: '\x1b[30m', 38 | FgRed: '\x1b[31m', 39 | FgGreen: '\x1b[32m', 40 | FgYellow: '\x1b[33m', 41 | FgBlue: '\x1b[34m', 42 | FgMagenta: '\x1b[35m', 43 | FgCyan: '\x1b[36m', 44 | FgWhite: '\x1b[37m', 45 | BgBlack: '\x1b[40m', 46 | BgRed: '\x1b[41m', 47 | BgGreen: '\x1b[42m', 48 | BgYellow: '\x1b[43m', 49 | BgBlue: '\x1b[44m', 50 | BgMagenta: '\x1b[45m', 51 | BgCyan: '\x1b[46m', 52 | BgWhite: '\x1b[47m', 53 | } as const; 54 | -------------------------------------------------------------------------------- /pages/content-runtime/src/Root.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import App from '@src/App'; 3 | // eslint-disable-next-line 4 | // @ts-ignore 5 | import injectedStyle from '@src/index.css?inline'; 6 | 7 | export function mount() { 8 | const root = document.createElement('div'); 9 | root.id = 'chrome-extension-boilerplate-react-vite-runtime-content-view-root'; 10 | 11 | document.body.append(root); 12 | 13 | const rootIntoShadow = document.createElement('div'); 14 | rootIntoShadow.id = 'shadow-root'; 15 | 16 | const shadowRoot = root.attachShadow({ mode: 'open' }); 17 | 18 | if (navigator.userAgent.includes('Firefox')) { 19 | /** 20 | * In the firefox environment, adoptedStyleSheets cannot be used due to the bug 21 | * @url https://bugzilla.mozilla.org/show_bug.cgi?id=1770592 22 | * 23 | * Injecting styles into the document, this may cause style conflicts with the host page 24 | */ 25 | const styleElement = document.createElement('style'); 26 | styleElement.innerHTML = injectedStyle; 27 | shadowRoot.appendChild(styleElement); 28 | } else { 29 | /** Inject styles into shadow dom */ 30 | const globalStyleSheet = new CSSStyleSheet(); 31 | globalStyleSheet.replaceSync(injectedStyle); 32 | shadowRoot.adoptedStyleSheets = [globalStyleSheet]; 33 | } 34 | 35 | shadowRoot.appendChild(rootIntoShadow); 36 | createRoot(rootIntoShadow).render(); 37 | } 38 | -------------------------------------------------------------------------------- /packages/shared/lib/hooks/useStorage.tsx: -------------------------------------------------------------------------------- 1 | import { useSyncExternalStore } from 'react'; 2 | import type { BaseStorage } from '@extension/storage'; 3 | 4 | type WrappedPromise = ReturnType; 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | const storageMap: Map, WrappedPromise> = new Map(); 7 | 8 | export function useStorage< 9 | Storage extends BaseStorage, 10 | Data = Storage extends BaseStorage ? Data : unknown, 11 | >(storage: Storage) { 12 | const _data = useSyncExternalStore(storage.subscribe, storage.getSnapshot); 13 | 14 | if (!storageMap.has(storage)) { 15 | storageMap.set(storage, wrapPromise(storage.get())); 16 | } 17 | if (_data !== null) { 18 | storageMap.set(storage, { read: () => _data }); 19 | } 20 | 21 | return (_data ?? storageMap.get(storage)!.read()) as Exclude>; 22 | } 23 | 24 | function wrapPromise(promise: Promise) { 25 | let status = 'pending'; 26 | let result: R; 27 | const suspender = promise.then( 28 | r => { 29 | status = 'success'; 30 | result = r; 31 | }, 32 | e => { 33 | status = 'error'; 34 | result = e; 35 | }, 36 | ); 37 | 38 | return { 39 | read() { 40 | switch (status) { 41 | case 'pending': 42 | throw suspender; 43 | case 'error': 44 | throw result; 45 | default: 46 | return result; 47 | } 48 | }, 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /packages/hmr/lib/initializers/initReloadServer.ts: -------------------------------------------------------------------------------- 1 | import type { WebSocket } from 'ws'; 2 | import { WebSocketServer } from 'ws'; 3 | import { BUILD_COMPLETE, DO_UPDATE, DONE_UPDATE, LOCAL_RELOAD_SOCKET_PORT, LOCAL_RELOAD_SOCKET_URL } from '../constant'; 4 | import MessageInterpreter from '../interpreter'; 5 | 6 | const clientsThatNeedToUpdate: Set = new Set(); 7 | 8 | function initReloadServer() { 9 | const wss = new WebSocketServer({ port: LOCAL_RELOAD_SOCKET_PORT }); 10 | 11 | wss.on('listening', () => { 12 | console.log(`[HMR] Server listening at ${LOCAL_RELOAD_SOCKET_URL}`); 13 | }); 14 | 15 | wss.on('connection', ws => { 16 | clientsThatNeedToUpdate.add(ws); 17 | 18 | ws.addEventListener('close', () => { 19 | clientsThatNeedToUpdate.delete(ws); 20 | }); 21 | 22 | ws.addEventListener('message', event => { 23 | if (typeof event.data !== 'string') return; 24 | 25 | const message = MessageInterpreter.receive(event.data); 26 | 27 | if (message.type === DONE_UPDATE) { 28 | ws.close(); 29 | } 30 | 31 | if (message.type === BUILD_COMPLETE) { 32 | clientsThatNeedToUpdate.forEach((ws: WebSocket) => 33 | ws.send(MessageInterpreter.send({ type: DO_UPDATE, id: message.id })), 34 | ); 35 | } 36 | }); 37 | }); 38 | 39 | wss.on('error', error => { 40 | console.error(`[HMR] Failed to start server at ${LOCAL_RELOAD_SOCKET_URL}`); 41 | throw error; 42 | }); 43 | } 44 | 45 | initReloadServer(); 46 | -------------------------------------------------------------------------------- /pages/content-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/content-ui", 3 | "version": "0.3.4", 4 | "description": "chrome extension - content ui", 5 | "type": "module", 6 | "private": true, 7 | "sideEffects": true, 8 | "files": [ 9 | "dist/**" 10 | ], 11 | "scripts": { 12 | "clean:bundle": "rimraf dist", 13 | "clean:node_modules": "pnpx rimraf node_modules", 14 | "clean:turbo": "rimraf .turbo", 15 | "clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo", 16 | "build:tailwindcss": "pnpm tailwindcss -i ./src/tailwind-input.css -o ./dist/tailwind-output.css -m", 17 | "build": "pnpm build:tailwindcss && vite build", 18 | "build:watch": "concurrently \"cross-env __DEV__=true vite build --mode development\" \"pnpm build:tailwindcss -- -w\"", 19 | "dev": "pnpm build:tailwindcss && pnpm build:watch", 20 | "lint": "eslint . --ext .ts,.tsx", 21 | "lint:fix": "pnpm lint --fix", 22 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 23 | "type-check": "tsc --noEmit" 24 | }, 25 | "dependencies": { 26 | "@extension/shared": "workspace:*", 27 | "@extension/storage": "workspace:*", 28 | "@extension/ui": "workspace:*" 29 | }, 30 | "devDependencies": { 31 | "@extension/tailwindcss-config": "workspace:*", 32 | "@extension/tsconfig": "workspace:*", 33 | "@extension/hmr": "workspace:*", 34 | "@extension/vite-config": "workspace:*", 35 | "concurrently": "^9.0.1", 36 | "cross-env": "^7.0.3" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /chrome-extension/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { defineConfig, type PluginOption } from "vite"; 3 | import libAssetsPlugin from '@laynezh/vite-plugin-lib-assets'; 4 | import makeManifestPlugin from './utils/plugins/make-manifest-plugin'; 5 | import { watchPublicPlugin, watchRebuildPlugin } from '@extension/hmr'; 6 | import { isDev, isProduction, watchOption } from '@extension/vite-config'; 7 | 8 | const rootDir = resolve(__dirname); 9 | const srcDir = resolve(rootDir, 'src'); 10 | 11 | const outDir = resolve(rootDir, '..', 'dist'); 12 | export default defineConfig({ 13 | resolve: { 14 | alias: { 15 | '@root': rootDir, 16 | '@src': srcDir, 17 | '@assets': resolve(srcDir, 'assets'), 18 | }, 19 | }, 20 | plugins: [ 21 | libAssetsPlugin({ 22 | outputPath: outDir, 23 | }) as PluginOption, 24 | watchPublicPlugin(), 25 | makeManifestPlugin({ outDir }), 26 | isDev && watchRebuildPlugin({ reload: true }), 27 | ], 28 | publicDir: resolve(rootDir, 'public'), 29 | build: { 30 | lib: { 31 | formats: ['iife'], 32 | entry: resolve(__dirname, 'src/background/index.ts'), 33 | name: 'BackgroundScript', 34 | fileName: 'background', 35 | }, 36 | outDir, 37 | emptyOutDir: false, 38 | sourcemap: isDev, 39 | minify: isProduction, 40 | reportCompressedSize: isProduction, 41 | watch: watchOption, 42 | rollupOptions: { 43 | external: ['chrome'], 44 | }, 45 | }, 46 | envDir: '../', 47 | }); 48 | -------------------------------------------------------------------------------- /pages/new-tab/src/NewTab.tsx: -------------------------------------------------------------------------------- 1 | import '@src/NewTab.css'; 2 | import '@src/NewTab.scss'; 3 | import { useStorage, withErrorBoundary, withSuspense } from '@extension/shared'; 4 | import { exampleThemeStorage } from '@extension/storage'; 5 | import { Button } from '@extension/ui'; 6 | import { t } from '@extension/i18n'; 7 | 8 | const NewTab = () => { 9 | const theme = useStorage(exampleThemeStorage); 10 | const isLight = theme === 'light'; 11 | const logo = isLight ? 'new-tab/logo_horizontal.svg' : 'new-tab/logo_horizontal_dark.svg'; 12 | const goGithubSite = () => 13 | chrome.tabs.create({ url: 'https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite' }); 14 | 15 | console.log(t('hello', 'World')); 16 | return ( 17 |
18 |
19 | 22 |

23 | Edit pages/new-tab/src/NewTab.tsx 24 |

25 |
The color of this paragraph is defined using SASS.
26 | 29 |
30 |
31 | ); 32 | }; 33 | 34 | export default withErrorBoundary(withSuspense(NewTab,
{t('loading')}
),
Error Occur
); 35 | -------------------------------------------------------------------------------- /packages/storage/lib/base/types.ts: -------------------------------------------------------------------------------- 1 | import type { StorageEnum } from './enums'; 2 | 3 | export type ValueOrUpdate = D | ((prev: D) => Promise | D); 4 | 5 | export type BaseStorage = { 6 | get: () => Promise; 7 | set: (value: ValueOrUpdate) => Promise; 8 | getSnapshot: () => D | null; 9 | subscribe: (listener: () => void) => () => void; 10 | }; 11 | 12 | export type StorageConfig = { 13 | /** 14 | * Assign the {@link StorageEnum} to use. 15 | * @default Local 16 | */ 17 | storageEnum?: StorageEnum; 18 | /** 19 | * Only for {@link StorageEnum.Session}: Grant Content scripts access to storage area? 20 | * @default false 21 | */ 22 | sessionAccessForContentScripts?: boolean; 23 | /** 24 | * Keeps state live in sync between all instances of the extension. Like between popup, side panel and content scripts. 25 | * To allow chrome background scripts to stay in sync as well, use {@link StorageEnum.Session} storage area with 26 | * {@link StorageConfig.sessionAccessForContentScripts} potentially also set to true. 27 | * @see https://stackoverflow.com/a/75637138/2763239 28 | * @default false 29 | */ 30 | liveUpdate?: boolean; 31 | /** 32 | * An optional props for converting values from storage and into it. 33 | * @default undefined 34 | */ 35 | serialization?: { 36 | /** 37 | * convert non-native values to string to be saved in storage 38 | */ 39 | serialize: (value: D) => string; 40 | /** 41 | * convert string value from storage to non-native values 42 | */ 43 | deserialize: (text: string) => D; 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /packages/i18n/lib/i18n-dev.ts: -------------------------------------------------------------------------------- 1 | import type { DevLocale, MessageKey } from './type'; 2 | import { defaultLocale, getMessageFromLocale } from './getMessageFromLocale'; 3 | 4 | type I18nValue = { 5 | message: string; 6 | placeholders?: Record; 7 | }; 8 | 9 | function translate(key: MessageKey, substitutions?: string | string[]) { 10 | const value = getMessageFromLocale(t.devLocale)[key] as I18nValue; 11 | let message = value.message; 12 | /** 13 | * This is a placeholder replacement logic. But it's not perfect. 14 | * It just imitates the behavior of the Chrome extension i18n API. 15 | * Please check the official document for more information And double-check the behavior on production build. 16 | * 17 | * @url https://developer.chrome.com/docs/extensions/how-to/ui/localization-message-formats#placeholders 18 | */ 19 | if (value.placeholders) { 20 | Object.entries(value.placeholders).forEach(([key, { content }]) => { 21 | if (!content) { 22 | return; 23 | } 24 | message = message.replace(new RegExp(`\\$${key}\\$`, 'gi'), content); 25 | }); 26 | } 27 | if (!substitutions) { 28 | return message; 29 | } 30 | if (Array.isArray(substitutions)) { 31 | return substitutions.reduce((acc, cur, idx) => acc.replace(`$${idx + 1}`, cur), message); 32 | } 33 | return message.replace(/\$(\d+)/, substitutions); 34 | } 35 | 36 | function removePlaceholder(message: string) { 37 | return message.replace(/\$\d+/g, ''); 38 | } 39 | 40 | export const t = (...args: Parameters) => { 41 | return removePlaceholder(translate(...args)); 42 | }; 43 | 44 | t.devLocale = defaultLocale as DevLocale; 45 | -------------------------------------------------------------------------------- /pages/devtools-panel/src/Panel.tsx: -------------------------------------------------------------------------------- 1 | import '@src/Panel.css'; 2 | import { useStorage, withErrorBoundary, withSuspense } from '@extension/shared'; 3 | import { exampleThemeStorage } from '@extension/storage'; 4 | import type { ComponentPropsWithoutRef } from 'react'; 5 | 6 | const Panel = () => { 7 | const theme = useStorage(exampleThemeStorage); 8 | const isLight = theme === 'light'; 9 | const logo = isLight ? 'devtools-panel/logo_horizontal.svg' : 'devtools-panel/logo_horizontal_dark.svg'; 10 | const goGithubSite = () => 11 | chrome.tabs.create({ url: 'https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite' }); 12 | 13 | return ( 14 |
15 |
16 | 19 |

20 | Edit pages/devtools-panel/src/Panel.tsx 21 |

22 | Toggle theme 23 |
24 |
25 | ); 26 | }; 27 | 28 | const ToggleButton = (props: ComponentPropsWithoutRef<'button'>) => { 29 | const theme = useStorage(exampleThemeStorage); 30 | return ( 31 | 41 | ); 42 | }; 43 | 44 | export default withErrorBoundary(withSuspense(Panel,
Loading ...
),
Error Occur
); 45 | -------------------------------------------------------------------------------- /pages/side-panel/src/SidePanel.tsx: -------------------------------------------------------------------------------- 1 | import '@src/SidePanel.css'; 2 | import { useStorage, withErrorBoundary, withSuspense } from '@extension/shared'; 3 | import { exampleThemeStorage } from '@extension/storage'; 4 | import type { ComponentPropsWithoutRef } from 'react'; 5 | 6 | const SidePanel = () => { 7 | const theme = useStorage(exampleThemeStorage); 8 | const isLight = theme === 'light'; 9 | const logo = isLight ? 'side-panel/logo_vertical.svg' : 'side-panel/logo_vertical_dark.svg'; 10 | const goGithubSite = () => 11 | chrome.tabs.create({ url: 'https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite' }); 12 | 13 | return ( 14 |
15 |
16 | 19 |

20 | Edit pages/side-panel/src/SidePanel.tsx 21 |

22 | Toggle theme 23 |
24 |
25 | ); 26 | }; 27 | 28 | const ToggleButton = (props: ComponentPropsWithoutRef<'button'>) => { 29 | const theme = useStorage(exampleThemeStorage); 30 | return ( 31 | 41 | ); 42 | }; 43 | 44 | export default withErrorBoundary(withSuspense(SidePanel,
Loading ...
),
Error Occur
); 45 | -------------------------------------------------------------------------------- /packages/storage/lib/base/enums.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Storage area type for persisting and exchanging data. 3 | * @see https://developer.chrome.com/docs/extensions/reference/storage/#overview 4 | */ 5 | export enum StorageEnum { 6 | /** 7 | * Persist data locally against browser restarts. Will be deleted by uninstalling the extension. 8 | * @default 9 | */ 10 | Local = 'local', 11 | /** 12 | * Uploads data to the users account in the cloud and syncs to the users browsers on other devices. Limits apply. 13 | */ 14 | Sync = 'sync', 15 | /** 16 | * Requires an [enterprise policy](https://www.chromium.org/administrators/configuring-policy-for-extensions) with a 17 | * json schema for company wide config. 18 | */ 19 | Managed = 'managed', 20 | /** 21 | * Only persist data until the browser is closed. Recommended for service workers which can shutdown anytime and 22 | * therefore need to restore their state. Set {@link SessionAccessLevelEnum} for permitting content scripts access. 23 | * @implements Chromes [Session Storage](https://developer.chrome.com/docs/extensions/reference/storage/#property-session) 24 | */ 25 | Session = 'session', 26 | } 27 | 28 | /** 29 | * Global access level requirement for the {@link StorageEnum.Session} Storage Area. 30 | * @implements Chromes [Session Access Level](https://developer.chrome.com/docs/extensions/reference/storage/#method-StorageArea-setAccessLevel) 31 | */ 32 | export enum SessionAccessLevelEnum { 33 | /** 34 | * Storage can only be accessed by Extension pages (not Content scripts). 35 | * @default 36 | */ 37 | ExtensionPagesOnly = 'TRUSTED_CONTEXTS', 38 | /** 39 | * Storage can be accessed by both Extension pages and Content scripts. 40 | */ 41 | ExtensionPagesAndContentScripts = 'TRUSTED_AND_UNTRUSTED_CONTEXTS', 42 | } 43 | -------------------------------------------------------------------------------- /chrome-extension/utils/plugins/make-manifest-plugin.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { resolve } from 'node:path'; 3 | import { pathToFileURL } from 'node:url'; 4 | import process from 'node:process'; 5 | import { colorLog, ManifestParser } from '@extension/dev-utils'; 6 | import type { PluginOption } from 'vite'; 7 | 8 | const rootDir = resolve(__dirname, '..', '..'); 9 | const manifestFile = resolve(rootDir, 'manifest.js'); 10 | 11 | const getManifestWithCacheBurst = (): Promise<{ default: chrome.runtime.ManifestV3 }> => { 12 | const withCacheBurst = (path: string) => `${path}?${Date.now().toString()}`; 13 | /** 14 | * In Windows, import() doesn't work without file:// protocol. 15 | * So, we need to convert path to file:// protocol. (url.pathToFileURL) 16 | */ 17 | if (process.platform === 'win32') { 18 | return import(withCacheBurst(pathToFileURL(manifestFile).href)); 19 | } 20 | 21 | return import(withCacheBurst(manifestFile)); 22 | }; 23 | 24 | export default function makeManifestPlugin(config: { outDir: string }): PluginOption { 25 | function makeManifest(manifest: chrome.runtime.ManifestV3, to: string) { 26 | if (!fs.existsSync(to)) { 27 | fs.mkdirSync(to); 28 | } 29 | const manifestPath = resolve(to, 'manifest.json'); 30 | 31 | const isFirefox = process.env.__FIREFOX__ === 'true'; 32 | fs.writeFileSync(manifestPath, ManifestParser.convertManifestToString(manifest, isFirefox ? 'firefox' : 'chrome')); 33 | 34 | colorLog(`Manifest file copy complete: ${manifestPath}`, 'success'); 35 | } 36 | 37 | return { 38 | name: 'make-manifest', 39 | buildStart() { 40 | this.addWatchFile(manifestFile); 41 | }, 42 | async writeBundle() { 43 | const outDir = config.outDir; 44 | const manifest = await getManifestWithCacheBurst(); 45 | makeManifest(manifest.default, outDir); 46 | }, 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /tests/e2e/utils/extension-path.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns the Chrome extension path. 3 | * @param browser 4 | * @returns path to the Chrome extension 5 | */ 6 | export const getChromeExtensionPath = async (browser: WebdriverIO.Browser) => { 7 | await browser.url('chrome://extensions/'); 8 | /** 9 | * https://webdriver.io/docs/extension-testing/web-extensions/#test-popup-modal-in-chrome 10 | * ```ts 11 | * const extensionItem = await $('extensions-item').getElement(); 12 | * ``` 13 | * The above code is not working. I guess it's because the shadow root is not accessible. 14 | * So I used the following code to access the shadow root manually. 15 | * 16 | * @url https://github.com/webdriverio/webdriverio/issues/13521 17 | * @url https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/issues/786 18 | */ 19 | const extensionItem = await (async () => { 20 | const extensionsManager = await $('extensions-manager').getElement(); 21 | const itemList = await extensionsManager.shadow$('#container > #viewManager > extensions-item-list'); 22 | return await itemList.shadow$('extensions-item'); 23 | })(); 24 | 25 | const extensionId = await extensionItem.getAttribute('id'); 26 | 27 | if (!extensionId) { 28 | throw new Error('Extension ID not found'); 29 | } 30 | 31 | return `chrome-extension://${extensionId}`; 32 | }; 33 | 34 | /** 35 | * Returns the Firefox extension path. 36 | * @param browser 37 | * @returns path to the Firefox extension 38 | */ 39 | export const getFirefoxExtensionPath = async (browser: WebdriverIO.Browser) => { 40 | await browser.url('about:debugging#/runtime/this-firefox'); 41 | const uuidElement = await browser.$('//dt[contains(text(), "Internal UUID")]/following-sibling::dd').getElement(); 42 | const internalUUID = await uuidElement.getText(); 43 | 44 | if (!internalUUID) { 45 | throw new Error('Internal UUID not found'); 46 | } 47 | 48 | return `moz-extension://${internalUUID}`; 49 | }; 50 | -------------------------------------------------------------------------------- /tests/e2e/config/wdio.browser.conf.ts: -------------------------------------------------------------------------------- 1 | import { config as baseConfig } from './wdio.conf'; 2 | import path from 'node:path'; 3 | import url from 'node:url'; 4 | import fs from 'node:fs/promises'; 5 | import { getChromeExtensionPath, getFirefoxExtensionPath } from '../utils/extension-path'; 6 | 7 | const isFirefox = process.env.__FIREFOX__ === 'true'; 8 | const isCI = process.env.CI === 'true'; 9 | 10 | const archiveName = isFirefox ? 'extension.xpi' : 'extension.zip'; 11 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); 12 | const extPath = path.join(__dirname, `../../../dist-zip/${archiveName}`); 13 | const bundledExtension = (await fs.readFile(extPath)).toString('base64'); 14 | 15 | const chromeCapabilities = { 16 | browserName: 'chrome', 17 | acceptInsecureCerts: true, 18 | 'goog:chromeOptions': { 19 | args: [ 20 | '--disable-web-security', 21 | '--disable-gpu', 22 | '--no-sandbox', 23 | '--disable-dev-shm-usage', 24 | ...(isCI ? ['--headless'] : []), 25 | ], 26 | prefs: { 'extensions.ui.developer_mode': true }, 27 | extensions: [bundledExtension], 28 | }, 29 | }; 30 | 31 | const firefoxCapabilities = { 32 | browserName: 'firefox', 33 | acceptInsecureCerts: true, 34 | 'moz:firefoxOptions': { 35 | args: [...(isCI ? ['--headless'] : [])], 36 | }, 37 | }; 38 | 39 | export const config: WebdriverIO.Config = { 40 | ...baseConfig, 41 | capabilities: isFirefox ? [firefoxCapabilities] : [chromeCapabilities], 42 | 43 | maxInstances: isCI ? 10 : 1, 44 | logLevel: 'error', 45 | execArgv: isCI ? [] : ['--inspect'], 46 | before: async ({ browserName }: WebdriverIO.Capabilities, _specs, browser: WebdriverIO.Browser) => { 47 | if (browserName === 'firefox') { 48 | await browser.installAddOn(bundledExtension, true); 49 | 50 | browser.addCommand('getExtensionPath', async () => getFirefoxExtensionPath(browser)); 51 | } else if (browserName === 'chrome') { 52 | browser.addCommand('getExtensionPath', async () => getChromeExtensionPath(browser)); 53 | } 54 | }, 55 | afterTest: async () => { 56 | if (!isCI) { 57 | await browser.pause(500); 58 | } 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /chrome-extension/manifest.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import deepmerge from 'deepmerge'; 3 | 4 | const packageJson = JSON.parse(fs.readFileSync('../package.json', 'utf8')); 5 | 6 | const isFirefox = process.env.__FIREFOX__ === 'true'; 7 | 8 | const sidePanelConfig = { 9 | side_panel: { 10 | default_path: 'side-panel/index.html', 11 | }, 12 | permissions: ['sidePanel'], 13 | }; 14 | 15 | /** 16 | * After changing, please reload the extension at `chrome://extensions` 17 | * @type {chrome.runtime.ManifestV3} 18 | */ 19 | const manifest = deepmerge( 20 | { 21 | manifest_version: 3, 22 | default_locale: 'en', 23 | /** 24 | * if you want to support multiple languages, you can use the following reference 25 | * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Internationalization 26 | */ 27 | name: '__MSG_extensionName__', 28 | version: packageJson.version, 29 | description: '__MSG_extensionDescription__', 30 | host_permissions: [''], 31 | permissions: ['storage', 'scripting', 'tabs', 'notifications'], 32 | options_page: 'options/index.html', 33 | background: { 34 | service_worker: 'background.iife.js', 35 | type: 'module', 36 | }, 37 | action: { 38 | default_popup: 'popup/index.html', 39 | default_icon: 'icon-34.png', 40 | }, 41 | chrome_url_overrides: { 42 | newtab: 'new-tab/index.html', 43 | }, 44 | icons: { 45 | 128: 'icon-128.png', 46 | }, 47 | content_scripts: [ 48 | { 49 | matches: ['http://*/*', 'https://*/*', ''], 50 | js: ['content/index.iife.js'], 51 | }, 52 | { 53 | matches: ['http://*/*', 'https://*/*', ''], 54 | js: ['content-ui/index.iife.js'], 55 | }, 56 | { 57 | matches: ['http://*/*', 'https://*/*', ''], 58 | css: ['content.css'], // public folder 59 | }, 60 | ], 61 | devtools_page: 'devtools/index.html', 62 | web_accessible_resources: [ 63 | { 64 | resources: ['*.js', '*.css', '*.svg', 'icon-128.png', 'icon-34.png'], 65 | matches: ['*://*/*'], 66 | }, 67 | ], 68 | }, 69 | !isFirefox && sidePanelConfig, 70 | ); 71 | 72 | export default manifest; 73 | -------------------------------------------------------------------------------- /packages/hmr/lib/plugins/watch-rebuild-plugin.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import type { PluginOption } from 'vite'; 4 | import { WebSocket } from 'ws'; 5 | import MessageInterpreter from '../interpreter'; 6 | import { BUILD_COMPLETE, LOCAL_RELOAD_SOCKET_URL } from '../constant'; 7 | import type { PluginConfig } from '../types'; 8 | 9 | const injectionsPath = path.resolve(__dirname, '..', '..', '..', 'build', 'injections'); 10 | 11 | const refreshCode = fs.readFileSync(path.resolve(injectionsPath, 'refresh.js'), 'utf-8'); 12 | const reloadCode = fs.readFileSync(path.resolve(injectionsPath, 'reload.js'), 'utf-8'); 13 | 14 | export function watchRebuildPlugin(config: PluginConfig): PluginOption { 15 | let ws: WebSocket | null = null; 16 | 17 | const id = Math.random().toString(36); 18 | let reconnectTries = 0; 19 | 20 | const { refresh, reload } = config; 21 | const hmrCode = (refresh ? refreshCode : '') + (reload ? reloadCode : ''); 22 | 23 | function initializeWebSocket() { 24 | ws = new WebSocket(LOCAL_RELOAD_SOCKET_URL); 25 | 26 | ws.onopen = () => { 27 | console.log(`[HMR] Connected to dev-server at ${LOCAL_RELOAD_SOCKET_URL}`); 28 | }; 29 | 30 | ws.onerror = () => { 31 | console.error(`[HMR] Failed to connect server at ${LOCAL_RELOAD_SOCKET_URL}`); 32 | console.warn('Retrying in 3 seconds...'); 33 | ws = null; 34 | 35 | if (reconnectTries <= 2) { 36 | setTimeout(() => { 37 | reconnectTries++; 38 | initializeWebSocket(); 39 | }, 3_000); 40 | } else { 41 | console.error(`[HMR] Cannot establish connection to server at ${LOCAL_RELOAD_SOCKET_URL}`); 42 | } 43 | }; 44 | } 45 | 46 | return { 47 | name: 'watch-rebuild', 48 | writeBundle() { 49 | config.onStart?.(); 50 | if (!ws) { 51 | initializeWebSocket(); 52 | return; 53 | } 54 | /** 55 | * When the build is complete, send a message to the reload server. 56 | * The reload server will send a message to the client to reload or refresh the extension. 57 | */ 58 | ws.send(MessageInterpreter.send({ type: BUILD_COMPLETE, id })); 59 | }, 60 | generateBundle(_options, bundle) { 61 | for (const module of Object.values(bundle)) { 62 | if (module.type === 'chunk') { 63 | module.code = `(function() {let __HMR_ID = "${id}";\n` + hmrCode + '\n' + '})();' + '\n' + module.code; 64 | } 65 | } 66 | }, 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /packages/hmr/lib/plugins/make-entry-point-plugin.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import type { PluginOption } from 'vite'; 4 | 5 | /** 6 | * make entry point file for content script cache busting 7 | */ 8 | export function makeEntryPointPlugin(): PluginOption { 9 | const cleanupTargets = new Set(); 10 | const isFirefox = process.env.__FIREFOX__ === 'true'; 11 | 12 | return { 13 | name: 'make-entry-point-plugin', 14 | generateBundle(options, bundle) { 15 | const outputDir = options.dir; 16 | 17 | if (!outputDir) { 18 | throw new Error('Output directory not found'); 19 | } 20 | 21 | for (const module of Object.values(bundle)) { 22 | const fileName = path.basename(module.fileName); 23 | const newFileName = fileName.replace('.js', '_dev.js'); 24 | 25 | switch (module.type) { 26 | case 'asset': 27 | if (fileName.endsWith('.map')) { 28 | cleanupTargets.add(path.resolve(outputDir, fileName)); 29 | 30 | const originalFileName = fileName.replace('.map', ''); 31 | const replacedSource = String(module.source).replaceAll(originalFileName, newFileName); 32 | 33 | module.source = ''; 34 | fs.writeFileSync(path.resolve(outputDir, newFileName), replacedSource); 35 | break; 36 | } 37 | break; 38 | 39 | case 'chunk': { 40 | fs.writeFileSync(path.resolve(outputDir, newFileName), module.code); 41 | 42 | if (isFirefox) { 43 | const contentDirectory = extractContentDir(outputDir); 44 | module.code = `import(browser.runtime.getURL("${contentDirectory}/${newFileName}"));`; 45 | } else { 46 | module.code = `import('./${newFileName}');`; 47 | } 48 | break; 49 | } 50 | } 51 | } 52 | }, 53 | closeBundle() { 54 | cleanupTargets.forEach(target => { 55 | fs.unlinkSync(target); 56 | }); 57 | }, 58 | }; 59 | } 60 | 61 | /** 62 | * Extract content directory from output directory for Firefox 63 | * @param outputDir 64 | */ 65 | function extractContentDir(outputDir: string) { 66 | const parts = outputDir.split(path.sep); 67 | const distIndex = parts.indexOf('dist'); 68 | 69 | if (distIndex !== -1 && distIndex < parts.length - 1) { 70 | return parts.slice(distIndex + 1); 71 | } 72 | 73 | throw new Error('Output directory does not contain "dist"'); 74 | } 75 | -------------------------------------------------------------------------------- /pages/content/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /pages/content-ui/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /pages/devtools/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /pages/popup/src/Popup.tsx: -------------------------------------------------------------------------------- 1 | import '@src/Popup.css'; 2 | import { useStorage, withErrorBoundary, withSuspense } from '@extension/shared'; 3 | import { exampleThemeStorage } from '@extension/storage'; 4 | import type { ComponentPropsWithoutRef } from 'react'; 5 | 6 | const notificationOptions = { 7 | type: 'basic', 8 | iconUrl: chrome.runtime.getURL('icon-34.png'), 9 | title: 'Injecting content script error', 10 | message: 'You cannot inject script here!', 11 | } as const; 12 | 13 | const Popup = () => { 14 | const theme = useStorage(exampleThemeStorage); 15 | const isLight = theme === 'light'; 16 | const logo = isLight ? 'popup/logo_vertical.svg' : 'popup/logo_vertical_dark.svg'; 17 | const goGithubSite = () => 18 | chrome.tabs.create({ url: 'https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite' }); 19 | 20 | const injectContentScript = async () => { 21 | const [tab] = await chrome.tabs.query({ currentWindow: true, active: true }); 22 | 23 | if (tab.url!.startsWith('about:') || tab.url!.startsWith('chrome:')) { 24 | chrome.notifications.create('inject-error', notificationOptions); 25 | } 26 | 27 | await chrome.scripting 28 | .executeScript({ 29 | target: { tabId: tab.id! }, 30 | files: ['/content-runtime/index.iife.js'], 31 | }) 32 | .catch(err => { 33 | // Handling errors related to other paths 34 | if (err.message.includes('Cannot access a chrome:// URL')) { 35 | chrome.notifications.create('inject-error', notificationOptions); 36 | } 37 | }); 38 | }; 39 | 40 | return ( 41 |
42 |
43 | 46 |

47 | Edit pages/popup/src/Popup.tsx 48 |

49 | 57 | Toggle theme 58 |
59 |
60 | ); 61 | }; 62 | 63 | const ToggleButton = (props: ComponentPropsWithoutRef<'button'>) => { 64 | const theme = useStorage(exampleThemeStorage); 65 | return ( 66 | 76 | ); 77 | }; 78 | 79 | export default withErrorBoundary(withSuspense(Popup,
Loading ...
),
Error Occur
); 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-extension-boilerplate-react-vite", 3 | "version": "0.3.4", 4 | "description": "chrome extension boilerplate", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite.git" 9 | }, 10 | "type": "module", 11 | "scripts": { 12 | "clean:bundle": "rimraf dist && rimraf dist-zip && turbo clean:bundle", 13 | "clean:node_modules": "turbo daemon stop && pnpx rimraf node_modules && pnpx turbo clean:node_modules", 14 | "clean:turbo": "turbo daemon stop && rimraf .turbo && turbo clean:turbo", 15 | "clean": "pnpm clean:bundle && pnpm clean:turbo && pnpm clean:node_modules", 16 | "clean:install": "pnpm clean:node_modules && pnpm install --frozen-lockfile", 17 | "build": "pnpm clean:bundle && turbo ready && turbo build", 18 | "build:firefox": "pnpm clean:bundle && turbo ready && cross-env __FIREFOX__=true turbo build", 19 | "zip": "pnpm build && pnpm -F zipper zip", 20 | "zip:firefox": "pnpm build:firefox && cross-env __FIREFOX__=true pnpm -F zipper zip", 21 | "dev": "turbo ready && cross-env __DEV__=true turbo watch dev --concurrency 20", 22 | "dev:firefox": "turbo ready && cross-env __DEV__=true __FIREFOX__=true turbo watch dev --concurrency 20", 23 | "e2e": "pnpm build && pnpm zip && turbo e2e", 24 | "e2e:firefox": "pnpm build:firefox && pnpm zip:firefox && cross-env __FIREFOX__=true turbo e2e", 25 | "type-check": "turbo type-check", 26 | "lint": "turbo lint --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache", 27 | "lint:fix": "turbo lint:fix --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache", 28 | "prettier": "turbo prettier --continue -- --cache --cache-location node_modules/.cache/.prettiercache", 29 | "prepare": "husky", 30 | "update-version": "bash update_version.sh" 31 | }, 32 | "dependencies": { 33 | "eslint-plugin-tailwindcss": "^3.17.4", 34 | "react": "18.3.1", 35 | "react-dom": "18.3.1" 36 | }, 37 | "devDependencies": { 38 | "@types/chrome": "^0.0.270", 39 | "@types/node": "^20.16.5", 40 | "@types/react": "^18.3.3", 41 | "@types/react-dom": "^18.3.0", 42 | "@typescript-eslint/eslint-plugin": "^7.18.0", 43 | "@typescript-eslint/parser": "^7.18.0", 44 | "autoprefixer": "^10.4.20", 45 | "cross-env": "^7.0.3", 46 | "esbuild": "^0.23.0", 47 | "eslint": "8.57.0", 48 | "eslint-config-airbnb-typescript": "18.0.0", 49 | "eslint-config-prettier": "9.1.0", 50 | "eslint-plugin-import": "2.29.1", 51 | "eslint-plugin-jsx-a11y": "6.9.0", 52 | "eslint-plugin-prettier": "5.2.1", 53 | "eslint-plugin-react": "7.35.0", 54 | "eslint-plugin-react-hooks": "4.6.2", 55 | "husky": "^9.1.4", 56 | "lint-staged": "^15.2.7", 57 | "postcss": "^8.4.47", 58 | "prettier": "^3.3.3", 59 | "rimraf": "^6.0.1", 60 | "tailwindcss": "^3.4.14", 61 | "tslib": "^2.6.3", 62 | "turbo": "^2.0.12", 63 | "typescript": "5.5.4", 64 | "vite": "5.4.9" 65 | }, 66 | "lint-staged": { 67 | "*.{js,jsx,ts,tsx,json}": [ 68 | "prettier --write" 69 | ] 70 | }, 71 | "packageManager": "pnpm@9.9.0", 72 | "engines": { 73 | "node": ">=18.19.1" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /packages/zipper/lib/zip-bundle/index.ts: -------------------------------------------------------------------------------- 1 | import { createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs'; 2 | import { posix, resolve } from 'node:path'; 3 | import glob from 'fast-glob'; 4 | import { AsyncZipDeflate, Zip } from 'fflate'; 5 | 6 | // Converts bytes to megabytes 7 | function toMB(bytes: number): number { 8 | return bytes / 1024 / 1024; 9 | } 10 | 11 | // Creates the build directory if it doesn't exist 12 | function ensureBuildDirectoryExists(buildDirectory: string): void { 13 | if (!existsSync(buildDirectory)) { 14 | mkdirSync(buildDirectory, { recursive: true }); 15 | } 16 | } 17 | 18 | // Logs the package size and duration 19 | function logPackageSize(size: number, startTime: number): void { 20 | console.log(`Zip Package size: ${toMB(size).toFixed(2)} MB in ${Date.now() - startTime}ms`); 21 | } 22 | 23 | // Handles file streaming and zipping 24 | function streamFileToZip( 25 | absPath: string, 26 | relPath: string, 27 | zip: Zip, 28 | onAbort: () => void, 29 | onError: (error: Error) => void, 30 | ): void { 31 | const data = new AsyncZipDeflate(relPath, { level: 9 }); 32 | zip.add(data); 33 | 34 | createReadStream(absPath) 35 | .on('data', (chunk: Buffer) => data.push(chunk, false)) 36 | .on('end', () => data.push(new Uint8Array(0), true)) 37 | .on('error', error => { 38 | onAbort(); 39 | onError(error); 40 | }); 41 | } 42 | 43 | // Zips the bundle 44 | export const zipBundle = async ( 45 | { 46 | distDirectory, 47 | buildDirectory, 48 | archiveName, 49 | }: { 50 | distDirectory: string; 51 | buildDirectory: string; 52 | archiveName: string; 53 | }, 54 | withMaps = false, 55 | ): Promise => { 56 | ensureBuildDirectoryExists(buildDirectory); 57 | 58 | const zipFilePath = resolve(buildDirectory, archiveName); 59 | const output = createWriteStream(zipFilePath); 60 | 61 | const fileList = await glob( 62 | [ 63 | '**/*', // Pick all nested files 64 | ...(!withMaps ? ['!**/(*.js.map|*.css.map)'] : []), // Exclude source maps conditionally 65 | ], 66 | { 67 | cwd: distDirectory, 68 | onlyFiles: true, 69 | }, 70 | ); 71 | 72 | return new Promise((pResolve, pReject) => { 73 | let aborted = false; 74 | let totalSize = 0; 75 | const timer = Date.now(); 76 | const zip = new Zip((err, data, final) => { 77 | if (err) { 78 | pReject(err); 79 | } else { 80 | totalSize += data.length; 81 | output.write(data); 82 | if (final) { 83 | logPackageSize(totalSize, timer); 84 | output.end(); 85 | pResolve(); 86 | } 87 | } 88 | }); 89 | 90 | // Handle file read streams 91 | for (const file of fileList) { 92 | if (aborted) return; 93 | 94 | const absPath = resolve(distDirectory, file); 95 | const absPosixPath = posix.resolve(distDirectory, file); 96 | const relPosixPath = posix.relative(distDirectory, absPosixPath); 97 | 98 | console.log(`Adding file: ${relPosixPath}`); 99 | streamFileToZip( 100 | absPath, 101 | relPosixPath, 102 | zip, 103 | () => { 104 | aborted = true; 105 | zip.terminate(); 106 | }, 107 | error => pReject(`Error reading file ${absPath}: ${error.message}`), 108 | ); 109 | } 110 | 111 | zip.end(); 112 | }); 113 | }; 114 | -------------------------------------------------------------------------------- /tests/e2e/config/wdio.conf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WebdriverIO v9 configuration file 3 | * https://webdriver.io/docs/configurationfile 4 | */ 5 | export const config: WebdriverIO.Config = { 6 | runner: 'local', 7 | tsConfigPath: '../tsconfig.json', 8 | 9 | // 10 | // ================== 11 | // Specify Test Files 12 | // ================== 13 | // Define which test specs should run. The pattern is relative to the directory 14 | // of the configuration file being run. 15 | // 16 | // The specs are defined as an array of spec files (optionally using wildcards 17 | // that will be expanded). The test for each spec file will be run in a separate 18 | // worker process. In order to have a group of spec files run in the same worker 19 | // process simply enclose them in an array within the specs array. 20 | // 21 | // The path of the spec files will be resolved relative from the directory 22 | // of the config file unless it's absolute. 23 | specs: ['../specs/**/*.ts'], 24 | // Patterns to exclude. 25 | exclude: [], 26 | // 27 | // ============ 28 | // Capabilities 29 | // ============ 30 | // Define your capabilities here. WebdriverIO can run multiple capabilities at the same 31 | // time. Depending on the number of capabilities, WebdriverIO launches several test 32 | // sessions. Within your capabilities you can overwrite the spec and exclude options in 33 | // order to group specific specs to a specific capability. 34 | // 35 | // First, you can define how many instances should be started at the same time. Let's 36 | // say you have 3 different capabilities (Chrome, Firefox, and Safari) and you have 37 | // set maxInstances to 1; wdio will spawn 3 processes. Therefore, if you have 10 spec 38 | // files and you set maxInstances to 10, all spec files will get tested at the same time 39 | // and 30 processes will get spawned. The property handles how many capabilities 40 | // from the same test should run tests. 41 | // 42 | maxInstances: 10, 43 | // 44 | // If you have trouble getting all important capabilities together, check out the 45 | // Sauce Labs platform configurator - a great tool to configure your capabilities: 46 | // https://saucelabs.com/platform/platform-configurator 47 | // 48 | capabilities: [], 49 | 50 | // 51 | // =================== 52 | // Test Configurations 53 | // =================== 54 | // Define all options that are relevant for the WebdriverIO instance here 55 | // 56 | // Level of logging verbosity: trace | debug | info | warn | error | silent 57 | logLevel: 'info', 58 | 59 | // 60 | // If you only want to run your tests until a specific amount of tests have failed use 61 | // bail (default is 0 - don't bail, run all tests). 62 | bail: 0, 63 | // 64 | // Default timeout for all waitFor* commands. 65 | waitforTimeout: 10000, 66 | 67 | // 68 | // Default timeout in milliseconds for request 69 | // if browser driver or grid doesn't send response 70 | connectionRetryTimeout: 120000, 71 | 72 | // 73 | // Default request retries count 74 | connectionRetryCount: 3, 75 | 76 | // 77 | // Framework you want to run your specs with. 78 | // The following are supported: Mocha, Jasmine, and Cucumber 79 | // see also: https://webdriver.io/docs/frameworks 80 | // 81 | // Make sure you have the wdio adapter package for the specific framework installed 82 | // before running any tests. 83 | framework: 'mocha', 84 | 85 | // 86 | // Test reporter for stdout. 87 | // The only one supported by default is 'dot' 88 | // see also: https://webdriver.io/docs/dot-reporter 89 | reporters: ['spec'], 90 | 91 | // Options to be passed to Mocha. 92 | // See the full list at http://mochajs.org/ 93 | mochaOpts: { 94 | ui: 'bdd', 95 | timeout: 60000, 96 | }, 97 | }; 98 | -------------------------------------------------------------------------------- /packages/i18n/genenrate-i18n.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | /** 4 | * @url https://developer.chrome.com/docs/extensions/reference/api/i18n#support_multiple_languages 5 | */ 6 | const SUPPORTED_LANGUAGES = { 7 | ar: 'Arabic', 8 | am: 'Amharic', 9 | bg: 'Bulgarian', 10 | bn: 'Bengali', 11 | ca: 'Catalan', 12 | cs: 'Czech', 13 | da: 'Danish', 14 | de: 'German', 15 | el: 'Greek', 16 | en: 'English', 17 | en_AU: 'English (Australia)', 18 | en_GB: 'English (Great Britain)', 19 | en_US: 'English (USA)', 20 | es: 'Spanish', 21 | es_419: 'Spanish (Latin America and Caribbean)', 22 | et: 'Estonian', 23 | fa: 'Persian', 24 | fi: 'Finnish', 25 | fil: 'Filipino', 26 | fr: 'French', 27 | gu: 'Gujarati', 28 | he: 'Hebrew', 29 | hi: 'Hindi', 30 | hr: 'Croatian', 31 | hu: 'Hungarian', 32 | id: 'Indonesian', 33 | it: 'Italian', 34 | ja: 'Japanese', 35 | kn: 'Kannada', 36 | ko: 'Korean', 37 | lt: 'Lithuanian', 38 | lv: 'Latvian', 39 | ml: 'Malayalam', 40 | mr: 'Marathi', 41 | ms: 'Malay', 42 | nl: 'Dutch', 43 | no: 'Norwegian', 44 | pl: 'Polish', 45 | pt_BR: 'Portuguese (Brazil)', 46 | pt_PT: 'Portuguese (Portugal)', 47 | ro: 'Romanian', 48 | ru: 'Russian', 49 | sk: 'Slovak', 50 | sl: 'Slovenian', 51 | sr: 'Serbian', 52 | sv: 'Swedish', 53 | sw: 'Swahili', 54 | ta: 'Tamil', 55 | te: 'Telugu', 56 | th: 'Thai', 57 | tr: 'Turkish', 58 | uk: 'Ukrainian', 59 | vi: 'Vietnamese', 60 | zh_CN: 'Chinese (China)', 61 | zh_TW: 'Chinese (Taiwan)', 62 | }; 63 | 64 | const locales = fs.readdirSync('locales'); 65 | 66 | locales.forEach(locale => { 67 | if (!(locale in SUPPORTED_LANGUAGES)) { 68 | throw new Error(`Unsupported language: ${locale}`); 69 | } 70 | }); 71 | 72 | makeTypeFile(locales); 73 | makeGetMessageFromLocaleFile(locales); 74 | 75 | function makeTypeFile(locales) { 76 | const typeFile = `/** 77 | * This file is generated by generate-i18n.mjs 78 | * Do not edit this file directly 79 | */ 80 | ${locales.map(locale => `import type ${locale}Message from '../locales/${locale}/messages.json';`).join('\n')} 81 | 82 | export type MessageKey = ${locales.map(locale => `keyof typeof ${locale}Message`).join(' & ')}; 83 | 84 | export type DevLocale = ${locales.map(locale => `'${locale}'`).join(' | ')}; 85 | `; 86 | 87 | fs.writeFileSync('lib/type.ts', typeFile); 88 | } 89 | 90 | function makeGetMessageFromLocaleFile(locales) { 91 | const defaultLocaleCode = `(() => { 92 | const locales = ${JSON.stringify(locales).replace(/"/g, "'").replace(/,/g, ', ')}; 93 | const firstLocale = locales[0]; 94 | const defaultLocale = Intl.DateTimeFormat().resolvedOptions().locale.replace('-', '_'); 95 | if (locales.includes(defaultLocale)) { 96 | return defaultLocale; 97 | } 98 | const defaultLocaleWithoutRegion = defaultLocale.split('_')[0]; 99 | if (locales.includes(defaultLocaleWithoutRegion)) { 100 | return defaultLocaleWithoutRegion; 101 | } 102 | return firstLocale; 103 | })()`; 104 | 105 | const getMessageFromLocaleFile = `/** 106 | * This file is generated by generate-i18n.mjs 107 | * Do not edit this file directly 108 | */ 109 | ${locales.map(locale => `import ${locale}Message from '../locales/${locale}/messages.json';`).join('\n')} 110 | 111 | export function getMessageFromLocale(locale: string) { 112 | switch (locale) { 113 | ${locales 114 | .map( 115 | locale => ` case '${locale}': 116 | return ${locale}Message;`, 117 | ) 118 | .join('\n')} 119 | default: 120 | throw new Error('Unsupported locale'); 121 | } 122 | } 123 | 124 | export const defaultLocale = ${defaultLocaleCode}; 125 | `; 126 | fs.writeFileSync('lib/getMessageFromLocale.ts', getMessageFromLocaleFile); 127 | } 128 | -------------------------------------------------------------------------------- /packages/i18n/README.md: -------------------------------------------------------------------------------- 1 | # I18n Package 2 | 3 | This package provides a set of tools to help you internationalize your Chrome Extension. 4 | 5 | https://developer.chrome.com/docs/extensions/reference/api/i18n 6 | 7 | ## Installation 8 | 9 | If you want to use the i18n translation function in each pages, you need to add the following to the package.json file. 10 | 11 | ```json 12 | { 13 | "dependencies": { 14 | "@extension/i18n": "workspace:*" 15 | } 16 | } 17 | ``` 18 | 19 | Then run the following command to install the package. 20 | 21 | ```bash 22 | pnpm install 23 | ``` 24 | 25 | ## Manage translations 26 | 27 | You can manage translations in the `locales` directory. 28 | 29 | `locales/en/messages.json` 30 | 31 | ```json 32 | { 33 | "helloWorld": { 34 | "message": "Hello, World!" 35 | } 36 | } 37 | ``` 38 | 39 | `locales/ko/messages.json` 40 | 41 | ```json 42 | { 43 | "helloWorld": { 44 | "message": "안녕하세요, 여러분!" 45 | } 46 | } 47 | ``` 48 | 49 | ## Delete or Add a new language 50 | 51 | When you want to delete or add a new language, you don't need to edit some util files like `lib/types.ts` or `lib/getMessageFromLocale.ts`. 52 | That's because we provide a script to generate util files automatically by the `generate-i18n.mjs` file. 53 | 54 | Following the steps below to delete or add a new language. 55 | 56 | ### Delete a language 57 | 58 | If you want to delete unused languages, you can delete the corresponding directory in the `locales` directory. 59 | 60 | ``` 61 | locales 62 | ├── en 63 | │ └── messages.json 64 | └── ko // delete this directory 65 | └── messages.json 66 | ``` 67 | 68 | Then run the following command. (or just run `pnpm dev` or `pnpm build` on root) 69 | 70 | ```bash 71 | pnpm genenrate-i8n 72 | ``` 73 | 74 | ### Add a new language 75 | 76 | If you want to add a new language, you can create a new directory in the `locales` directory. 77 | 78 | ``` 79 | locales 80 | ├── en 81 | │ └── messages.json 82 | ├── ko 83 | │ └── messages.json 84 | └── ja // create this directory 85 | └── messages.json // and create this file 86 | ``` 87 | 88 | Then same as above, run the following command. (or just run `pnpm dev` or `pnpm build` on root) 89 | 90 | ```bash 91 | pnpm genenrate-i8n 92 | ``` 93 | 94 | 95 | ## Usage 96 | 97 | ### Translation function 98 | 99 | Just import the `t` function and use it to translate the key. 100 | 101 | ```typescript 102 | import { t } from '@extension/i18n'; 103 | 104 | console.log(t('loading')); // Loading... 105 | ``` 106 | 107 | ```typescript jsx 108 | import { t } from '@extension/i18n'; 109 | 110 | const Component = () => { 111 | return ( 112 | 115 | ); 116 | }; 117 | ``` 118 | 119 | ### Placeholders 120 | 121 | If you want to use placeholders, you can use the following format. 122 | 123 | > For more information, see the [Message Placeholders](https://developer.chrome.com/docs/extensions/how-to/ui/localization-message-formats#placeholders) section. 124 | 125 | `locales/en/messages.json` 126 | 127 | ```json 128 | { 129 | "greeting": { 130 | "description": "Greeting message", 131 | "message": "Hello, My name is $NAME$", 132 | "placeholders": { 133 | "name": { 134 | "content": "$1", 135 | "example": "John Doe" 136 | } 137 | } 138 | }, 139 | "hello": { 140 | "description": "Placeholder example", 141 | "message": "Hello $1" 142 | } 143 | } 144 | ``` 145 | 146 | `locales/ko/messages.json` 147 | 148 | ```json 149 | { 150 | "greeting": { 151 | "description": "인사 메시지", 152 | "message": "안녕하세요, 제 이름은 $NAME$입니다.", 153 | "placeholders": { 154 | "name": { 155 | "content": "$1", 156 | "example": "서종학" 157 | } 158 | } 159 | }, 160 | "hello": { 161 | "description": "Placeholder 예시", 162 | "message": "안녕 $1" 163 | } 164 | } 165 | ``` 166 | 167 | If you want to replace the placeholder, you can pass the value as the second argument. 168 | 169 | Function `t` has exactly the same interface as the `chrome.i18n.getMessage` function. 170 | 171 | ```typescript 172 | import { t } from '@extension/i18n'; 173 | 174 | console.log(t('greeting', 'John Doe')); // Hello, My name is John Doe 175 | console.log(t('greeting', ['John Doe'])); // Hello, My name is John Doe 176 | 177 | console.log(t('hello')); // Hello 178 | console.log(t('hello', 'World')); // Hello World 179 | console.log(t('hello', ['World'])); // Hello World 180 | ``` 181 | 182 | ### Locale setting on development 183 | 184 | If you want to show specific language, you can set the `devLocale` property. (only for development) 185 | 186 | ```typescript 187 | import { t } from '@extension/i18n'; 188 | 189 | t.devLocale = "ko"; 190 | 191 | console.log(t('hello')); // 안녕 192 | ``` 193 | 194 | ### Type Safety 195 | 196 | When you forget to add a key to all language's `messages.json` files, you will get a Typescript error. 197 | 198 | `locales/en/messages.json` 199 | 200 | ```json 201 | { 202 | "hello": { 203 | "message": "Hello World!" 204 | } 205 | } 206 | ``` 207 | 208 | `locales/ko/messages.json` 209 | 210 | ```json 211 | { 212 | "helloWorld": { 213 | "message": "안녕하세요, 여러분!" 214 | } 215 | } 216 | ``` 217 | 218 | ```typescript 219 | import { t } from '@extension/i18n'; 220 | 221 | // Error: TS2345: Argument of type "hello" is not assignable to parameter of type 222 | console.log(t('hello')); 223 | ``` 224 | -------------------------------------------------------------------------------- /packages/storage/lib/base/base.ts: -------------------------------------------------------------------------------- 1 | import type { BaseStorage, StorageConfig, ValueOrUpdate } from './types'; 2 | import { SessionAccessLevelEnum, StorageEnum } from './enums'; 3 | 4 | /** 5 | * Chrome reference error while running `processTailwindFeatures` in tailwindcss. 6 | * To avoid this, we need to check if the globalThis.chrome is available and add fallback logic. 7 | */ 8 | const chrome = globalThis.chrome; 9 | 10 | /** 11 | * Sets or updates an arbitrary cache with a new value or the result of an update function. 12 | */ 13 | async function updateCache(valueOrUpdate: ValueOrUpdate, cache: D | null): Promise { 14 | // Type guard to check if our value or update is a function 15 | function isFunction(value: ValueOrUpdate): value is (prev: D) => D | Promise { 16 | return typeof value === 'function'; 17 | } 18 | 19 | // Type guard to check in case of a function, if its a Promise 20 | function returnsPromise(func: (prev: D) => D | Promise): func is (prev: D) => Promise { 21 | // Use ReturnType to infer the return type of the function and check if it's a Promise 22 | return (func as (prev: D) => Promise) instanceof Promise; 23 | } 24 | 25 | if (isFunction(valueOrUpdate)) { 26 | // Check if the function returns a Promise 27 | if (returnsPromise(valueOrUpdate)) { 28 | return valueOrUpdate(cache as D); 29 | } else { 30 | return valueOrUpdate(cache as D); 31 | } 32 | } else { 33 | return valueOrUpdate; 34 | } 35 | } 36 | 37 | /** 38 | * If one session storage needs access from content scripts, we need to enable it globally. 39 | * @default false 40 | */ 41 | let globalSessionAccessLevelFlag: StorageConfig['sessionAccessForContentScripts'] = false; 42 | 43 | /** 44 | * Checks if the storage permission is granted in the manifest.json. 45 | */ 46 | function checkStoragePermission(storageEnum: StorageEnum): void { 47 | if (!chrome) { 48 | return; 49 | } 50 | 51 | if (chrome.storage[storageEnum] === undefined) { 52 | throw new Error(`Check your storage permission in manifest.json: ${storageEnum} is not defined`); 53 | } 54 | } 55 | 56 | /** 57 | * Creates a storage area for persisting and exchanging data. 58 | */ 59 | export function createStorage(key: string, fallback: D, config?: StorageConfig): BaseStorage { 60 | let cache: D | null = null; 61 | let initedCache = false; 62 | let listeners: Array<() => void> = []; 63 | 64 | const storageEnum = config?.storageEnum ?? StorageEnum.Local; 65 | const liveUpdate = config?.liveUpdate ?? false; 66 | 67 | const serialize = config?.serialization?.serialize ?? ((v: D) => v); 68 | const deserialize = config?.serialization?.deserialize ?? (v => v as D); 69 | 70 | // Set global session storage access level for StoryType.Session, only when not already done but needed. 71 | if ( 72 | globalSessionAccessLevelFlag === false && 73 | storageEnum === StorageEnum.Session && 74 | config?.sessionAccessForContentScripts === true 75 | ) { 76 | checkStoragePermission(storageEnum); 77 | chrome?.storage[storageEnum] 78 | .setAccessLevel({ 79 | accessLevel: SessionAccessLevelEnum.ExtensionPagesAndContentScripts, 80 | }) 81 | .catch(error => { 82 | console.warn(error); 83 | console.warn('Please call setAccessLevel into different context, like a background script.'); 84 | }); 85 | globalSessionAccessLevelFlag = true; 86 | } 87 | 88 | // Register life cycle methods 89 | const get = async (): Promise => { 90 | checkStoragePermission(storageEnum); 91 | const value = await chrome?.storage[storageEnum].get([key]); 92 | 93 | if (!value) { 94 | return fallback; 95 | } 96 | 97 | return deserialize(value[key]) ?? fallback; 98 | }; 99 | 100 | const _emitChange = () => { 101 | listeners.forEach(listener => listener()); 102 | }; 103 | 104 | const set = async (valueOrUpdate: ValueOrUpdate) => { 105 | if (initedCache === false) { 106 | cache = await get(); 107 | } 108 | cache = await updateCache(valueOrUpdate, cache); 109 | 110 | await chrome?.storage[storageEnum].set({ [key]: serialize(cache) }); 111 | _emitChange(); 112 | }; 113 | 114 | const subscribe = (listener: () => void) => { 115 | listeners = [...listeners, listener]; 116 | 117 | return () => { 118 | listeners = listeners.filter(l => l !== listener); 119 | }; 120 | }; 121 | 122 | const getSnapshot = () => { 123 | return cache; 124 | }; 125 | 126 | get().then(data => { 127 | cache = data; 128 | initedCache = true; 129 | _emitChange(); 130 | }); 131 | 132 | // Listener for live updates from the browser 133 | async function _updateFromStorageOnChanged(changes: { [key: string]: chrome.storage.StorageChange }) { 134 | // Check if the key we are listening for is in the changes object 135 | if (changes[key] === undefined) return; 136 | 137 | const valueOrUpdate: ValueOrUpdate = deserialize(changes[key].newValue); 138 | 139 | if (cache === valueOrUpdate) return; 140 | 141 | cache = await updateCache(valueOrUpdate, cache); 142 | 143 | _emitChange(); 144 | } 145 | 146 | // Register listener for live updates for our storage area 147 | if (liveUpdate) { 148 | chrome?.storage[storageEnum].onChanged.addListener(_updateFromStorageOnChanged); 149 | } 150 | 151 | return { 152 | get, 153 | set, 154 | getSnapshot, 155 | subscribe, 156 | }; 157 | } 158 | -------------------------------------------------------------------------------- /packages/ui/README.md: -------------------------------------------------------------------------------- 1 | # UI Package 2 | 3 | This package provides components that make up the UI. 4 | 5 | ## Installation 6 | 7 | First, move to the page you want to use. 8 | 9 | ```shell 10 | cd pages/options 11 | ``` 12 | 13 | Add the following to the dependencies in `package.json`. 14 | 15 | ```json 16 | { 17 | "dependencies": { 18 | "@extension/ui": "workspace:*" 19 | } 20 | } 21 | ``` 22 | 23 | Then, run `pnpm install`. 24 | 25 | ```shell 26 | pnpm install 27 | ``` 28 | 29 | Add the following to the `tailwind.config.ts` file. 30 | 31 | ```ts 32 | import baseConfig from '@extension/tailwindcss-config'; 33 | import { withUI } from '@extension/ui'; 34 | 35 | export default withUI({ 36 | ...baseConfig, 37 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 38 | }); 39 | ``` 40 | 41 | Add the following to the `index.tsx` file. 42 | 43 | ```tsx 44 | import '@extension/ui/dist/global.css'; 45 | ``` 46 | 47 | ## Add Component 48 | 49 | Add the following to the `lib/components/index.ts` file. 50 | 51 | ```tsx 52 | export * from './Button'; 53 | ``` 54 | 55 | Add the following to the `lib/components/Button.tsx` file. 56 | 57 | ```tsx 58 | import { ComponentPropsWithoutRef } from 'react'; 59 | import { cn } from '../utils'; 60 | 61 | export type ButtonProps = { 62 | theme?: 'light' | 'dark'; 63 | } & ComponentPropsWithoutRef<'button'>; 64 | 65 | export function Button({ theme, className, children, ...props }: ButtonProps) { 66 | return ( 67 | 76 | ); 77 | } 78 | ``` 79 | 80 | ## Usage 81 | 82 | ```tsx 83 | import { Button } from '@extension/ui'; 84 | 85 | export default function ToggleButton() { 86 | const [theme, setTheme] = useState<'light' | 'dark'>('light'); 87 | 88 | const toggle = () => { 89 | setTheme(theme === 'light' ? 'dark' : 'light'); 90 | }; 91 | 92 | return ( 93 | 96 | ); 97 | } 98 | ``` 99 | 100 | ## Modifying the tailwind config of the UI library 101 | 102 | Modify the `tailwind.config.ts` file to make global style changes to the package. 103 | 104 | ## Modifying the css variable of the UI library 105 | 106 | Modify the css variable in the `ui/lib/global.css` code to change the css variable of the package. 107 | 108 | ## Guide for Adding shadcn to the UI Package 109 | 110 | You can refer to the this [manual guide](https://ui.shadcn.com/docs/installation/manual) 111 | 112 | 1. Create components.json in packages/ui 113 | 114 | Create a file named `components.json` in the `packages/ui` directory with the following content: 115 | 116 | ```json 117 | { 118 | "$schema": "https://ui.shadcn.com/schema.json", 119 | "style": "default", 120 | "rsc": false, 121 | "tsx": true, 122 | "tailwind": { 123 | "config": "tailwind.config.ts", 124 | "css": "lib/global.css", 125 | "baseColor": "neutral", 126 | "cssVariables": true, 127 | "prefix": "" 128 | }, 129 | "aliases": { 130 | "components": "@/lib/components", 131 | "utils": "@/lib/utils", 132 | "ui": "@/lib/components/ui", 133 | "lib": "@/lib" 134 | } 135 | } 136 | ``` 137 | 138 | 2. Install dependencies 139 | 140 | Run the following command from the root of your project: 141 | 142 | ```shell 143 | pnpm add tailwindcss-animate class-variance-authority -F ui 144 | ``` 145 | 146 | 3. Edit `withUI.ts` in `lib` folder 147 | 148 | This configuration file is from the manual guide. You can refer to the manual guide to modify the configuration file. ([`Configure tailwind.config.js`](https://ui.shadcn.com/docs/installation/manual)) 149 | 150 | ```ts 151 | import deepmerge from 'deepmerge'; 152 | import type { Config } from 'tailwindcss/types/config'; 153 | import { fontFamily } from 'tailwindcss/defaultTheme'; 154 | import tailwindAnimate from 'tailwindcss-animate'; 155 | 156 | export function withUI(tailwindConfig: Config): Config { 157 | return deepmerge( 158 | shadcnConfig, 159 | deepmerge(tailwindConfig, { 160 | content: ['./node_modules/@extension/ui/lib/**/*.{tsx,ts,js,jsx}'], 161 | }), 162 | ); 163 | } 164 | 165 | const shadcnConfig = { 166 | darkMode: ['class'], 167 | theme: { 168 | container: { 169 | center: true, 170 | padding: '2rem', 171 | screens: { 172 | '2xl': '1400px', 173 | }, 174 | }, 175 | extend: { 176 | colors: { 177 | border: 'hsl(var(--border))', 178 | input: 'hsl(var(--input))', 179 | ring: 'hsl(var(--ring))', 180 | background: 'hsl(var(--background))', 181 | foreground: 'hsl(var(--foreground))', 182 | primary: { 183 | DEFAULT: 'hsl(var(--primary))', 184 | foreground: 'hsl(var(--primary-foreground))', 185 | }, 186 | secondary: { 187 | DEFAULT: 'hsl(var(--secondary))', 188 | foreground: 'hsl(var(--secondary-foreground))', 189 | }, 190 | destructive: { 191 | DEFAULT: 'hsl(var(--destructive))', 192 | foreground: 'hsl(var(--destructive-foreground))', 193 | }, 194 | muted: { 195 | DEFAULT: 'hsl(var(--muted))', 196 | foreground: 'hsl(var(--muted-foreground))', 197 | }, 198 | accent: { 199 | DEFAULT: 'hsl(var(--accent))', 200 | foreground: 'hsl(var(--accent-foreground))', 201 | }, 202 | popover: { 203 | DEFAULT: 'hsl(var(--popover))', 204 | foreground: 'hsl(var(--popover-foreground))', 205 | }, 206 | card: { 207 | DEFAULT: 'hsl(var(--card))', 208 | foreground: 'hsl(var(--card-foreground))', 209 | }, 210 | }, 211 | borderRadius: { 212 | lg: `var(--radius)`, 213 | md: `calc(var(--radius) - 2px)`, 214 | sm: 'calc(var(--radius) - 4px)', 215 | }, 216 | fontFamily: { 217 | sans: ['var(--font-sans)', ...fontFamily.sans], 218 | }, 219 | keyframes: { 220 | 'accordion-down': { 221 | from: { height: '0' }, 222 | to: { height: 'var(--radix-accordion-content-height)' }, 223 | }, 224 | 'accordion-up': { 225 | from: { height: 'var(--radix-accordion-content-height)' }, 226 | to: { height: '0' }, 227 | }, 228 | }, 229 | animation: { 230 | 'accordion-down': 'accordion-down 0.2s ease-out', 231 | 'accordion-up': 'accordion-up 0.2s ease-out', 232 | }, 233 | }, 234 | }, 235 | plugins: [tailwindAnimate], 236 | }; 237 | ``` 238 | 239 | 4. Edit `global.css` in `lib` folder 240 | 241 | This configuration also comes from the manual guide. You can refer to the manual guide to modify the configuration file. ([`Configure styles`](https://ui.shadcn.com/docs/installation/manual)) 242 | 243 | ```css 244 | @tailwind base; 245 | @tailwind components; 246 | @tailwind utilities; 247 | 248 | @layer base { 249 | :root { 250 | --background: 0 0% 100%; 251 | --foreground: 222.2 47.4% 11.2%; 252 | 253 | --muted: 210 40% 96.1%; 254 | --muted-foreground: 215.4 16.3% 46.9%; 255 | 256 | --popover: 0 0% 100%; 257 | --popover-foreground: 222.2 47.4% 11.2%; 258 | 259 | --border: 214.3 31.8% 91.4%; 260 | --input: 214.3 31.8% 91.4%; 261 | 262 | --card: 0 0% 100%; 263 | --card-foreground: 222.2 47.4% 11.2%; 264 | 265 | --primary: 222.2 47.4% 11.2%; 266 | --primary-foreground: 210 40% 98%; 267 | 268 | --secondary: 210 40% 96.1%; 269 | --secondary-foreground: 222.2 47.4% 11.2%; 270 | 271 | --accent: 210 40% 96.1%; 272 | --accent-foreground: 222.2 47.4% 11.2%; 273 | 274 | --destructive: 0 100% 50%; 275 | --destructive-foreground: 210 40% 98%; 276 | 277 | --ring: 215 20.2% 65.1%; 278 | 279 | --radius: 0.5rem; 280 | } 281 | 282 | .dark { 283 | --background: 224 71% 4%; 284 | --foreground: 213 31% 91%; 285 | 286 | --muted: 223 47% 11%; 287 | --muted-foreground: 215.4 16.3% 56.9%; 288 | 289 | --accent: 216 34% 17%; 290 | --accent-foreground: 210 40% 98%; 291 | 292 | --popover: 224 71% 4%; 293 | --popover-foreground: 215 20.2% 65.1%; 294 | 295 | --border: 216 34% 17%; 296 | --input: 216 34% 17%; 297 | 298 | --card: 224 71% 4%; 299 | --card-foreground: 213 31% 91%; 300 | 301 | --primary: 210 40% 98%; 302 | --primary-foreground: 222.2 47.4% 1.2%; 303 | 304 | --secondary: 222.2 47.4% 11.2%; 305 | --secondary-foreground: 210 40% 98%; 306 | 307 | --destructive: 0 63% 31%; 308 | --destructive-foreground: 210 40% 98%; 309 | 310 | --ring: 216 34% 17%; 311 | 312 | --radius: 0.5rem; 313 | } 314 | } 315 | 316 | @layer base { 317 | * { 318 | @apply border-border; 319 | } 320 | body { 321 | @apply bg-background text-foreground; 322 | font-feature-settings: "rlig" 1, "calt" 1; 323 | } 324 | } 325 | ``` 326 | 327 | 5. Add shadcn components 328 | 329 | Finally, run this command from the root of your project to add the button component: 330 | 331 | ```shell 332 | pnpm dlx shadcn@latest add button -c ./packages/ui 333 | ``` 334 | 335 | This will add the shadcn button component to your UI package. 336 | 337 | Remember to adjust any paths or package names if your project structure differs from the assumed layout in this guide. 338 | 339 | 6. Export components 340 | 341 | Make the `index.ts` file in the `components/ui` directory export the button component: 342 | 343 | ```ts 344 | export * from './button'; 345 | ``` 346 | 347 | Edit the `index.ts` file in the `packages/ui` directory to export the shadcn ui component: 348 | 349 | ```ts 350 | // export * from './lib/components'; // remove this line: duplicated button component 351 | export * from './lib/components/ui'; 352 | ``` 353 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | Logo 7 | 8 | 9 | ![](https://img.shields.io/badge/React-61DAFB?style=flat-square&logo=react&logoColor=black) 10 | ![](https://img.shields.io/badge/Typescript-3178C6?style=flat-square&logo=typescript&logoColor=white) 11 | ![](https://badges.aleen42.com/src/vitejs.svg) 12 | 13 | ![GitHub action badge](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/actions/workflows/build-zip.yml/badge.svg) 14 | ![GitHub action badge](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/actions/workflows/lint.yml/badge.svg) 15 | 16 | hits 17 | 18 | 19 | > This boilerplate 20 | > has [Legacy version](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/tree/legacy) 21 | 22 |
23 | 24 | > [!NOTE] 25 | > This project is listed in the [Awesome Vite](https://github.com/vitejs/awesome-vite) 26 | 27 | > [!TIP] 28 | > Share storage state between all pages 29 | > 30 | > https://github.com/user-attachments/assets/3b8e189f-6443-490e-a455-4f9570267f8c 31 | 32 | ## Table of Contents 33 | 34 | - [Intro](#intro) 35 | - [Features](#features) 36 | - [Structure](#structure) 37 | - [ChromeExtension](#structure-chrome-extension) 38 | - [Packages](#structure-packages) 39 | - [Pages](#structure-pages) 40 | - [Getting started](#getting-started) 41 | - [Chrome](#getting-started-chrome) 42 | - [Firefox](#getting-started-firefox) 43 | - [Install dependency](#install-dependency) 44 | - [For root](#install-dependency-for-root) 45 | - [For module](#install-dependency-for-module) 46 | - [Community](#community) 47 | - [Reference](#reference) 48 | - [Star History](#star-history) 49 | - [Contributors](#contributors) 50 | 51 | ## Intro 52 | 53 | This boilerplate is made for creating chrome extensions using React and Typescript. 54 | > The focus was on improving the build speed and development experience with Vite(Rollup) & Turborepo. 55 | 56 | ## Features 57 | 58 | - [React18](https://reactjs.org/) 59 | - [TypeScript](https://www.typescriptlang.org/) 60 | - [Tailwindcss](https://tailwindcss.com/) 61 | - [Vite](https://vitejs.dev/) 62 | - [Turborepo](https://turbo.build/repo) 63 | - [Prettier](https://prettier.io/) 64 | - [ESLint](https://eslint.org/) 65 | - [Chrome Extension Manifest Version 3](https://developer.chrome.com/docs/extensions/mv3/intro/) 66 | - [Custom I18n Package](/packages/i18n/) 67 | - [Custom HMR(Hot Module Rebuild) Plugin](/packages/hmr/) 68 | - [End to End Testing with WebdriverIO](https://webdriver.io/) 69 | 70 | ## Getting started: 71 | 72 | 1. When you're using Windows run this: 73 | - `git config --global core.eol lf` 74 | - `git config --global core.autocrlf input` 75 | #### This will change eol(End of line) to the same as on Linux/Mac, without this, you will have conflicts with your teammates with those systems and our bash script won't work 76 | 2. Clone this repository. 77 | 3. Change `extensionDescription` and `extensionName` in `messages.json` file in `packages/i18n/locales` folder. 78 | 4. Install pnpm globally: `npm install -g pnpm` (check your node version >= 18.19.1)) 79 | 5. Run `pnpm install` 80 | 81 | ### And then, depending on needs: 82 | 83 | ### For Chrome: 84 | 85 | 1. Run: 86 | - Dev: `pnpm dev` (On windows, you should run as administrator. [(Issue#456)](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/issues/456) 87 | - Prod: `pnpm build` 88 | 2. Open in browser - `chrome://extensions` 89 | 3. Check - `Developer mode` 90 | 4. Find and Click - `Load unpacked extension` 91 | 5. Select - `dist` folder at root 92 | 93 | ### For Firefox: 94 | 95 | 1. Run: 96 | - Dev: `pnpm dev:firefox` 97 | - Prod: `pnpm build:firefox` 98 | 2. Open in browser - `about:debugging#/runtime/this-firefox` 99 | 3. Find and Click - `Load Temporary Add-on...` 100 | 4. Select - `manifest.json` from `dist` folder at root 101 | 102 |

103 | Remember in firefox you add plugin in temporary mode, that's mean it'll disappear after each browser close. 104 | 105 | You have to do it on every browser launch. 106 |

107 | 108 | ## Install dependency for turborepo: 109 | 110 | ### For root: 111 | 112 | 1. Run `pnpm i -w` 113 | 114 | ### For module: 115 | 116 | 1. Run `pnpm i -F ` 117 | 118 | `package` - Name of the package you want to install e.g. `nodemon` \ 119 | `module-name` - You can find it inside each `package.json` under the key `name`, e.g. `@extension/content-script`, you can use only `content-script` without `@extension/` prefix 120 | 121 | ## Env Variables 122 | 123 | 1. Copy `.example.env` and paste it as `.env` in the same path 124 | 2. Add a new record inside `.env` 125 | 3. Add this key with type for value to `vite-env.d.ts` (root) to `ImportMetaEnv` 126 | 4. Then you can use it with `import.meta.env.{YOUR_KEY}` like with standard [Vite Env](https://vitejs.dev/guide/env-and-mode) 127 | 128 | #### If you want to set it for each package independently: 129 | 130 | 1. Create `.env` inside that package 131 | 2. Open related `vite.config.mts` and add `envDir: '.'` at the end of this config 132 | 3. Rest steps like above 133 | 134 | #### Remember you can't use global and local at the same time for the same package(It will be overwritten) 135 | 136 | ## Structure 137 | 138 | ### ChromeExtension 139 | 140 | Main app with background script, manifest 141 | 142 | - `manifest.js` - manifest for chrome extension 143 | - `src/background` - [background script](https://developer.chrome.com/docs/extensions/mv3/background_pages/) for chrome 144 | extension (`background.service_worker` in 145 | manifest.json) 146 | - `public/content.css` - content css for user's page injection 147 | 148 | ### Packages 149 | 150 | Some shared packages 151 | 152 | - `dev-utils` - utils for chrome extension development (manifest-parser, logger) 153 | - `i18n` - custom i18n package for chrome extension. provide i18n function with type safety and other validation. 154 | - `hmr` - custom HMR plugin for vite, injection script for reload/refresh, hmr dev-server 155 | - `shared` - shared code for entire project. (types, constants, custom hooks, components, etc.) 156 | - `storage` - helpers for [storage](https://developer.chrome.com/docs/extensions/reference/api/storage) easier integration with, e.g local, session storages 157 | - `tailwind-config` - shared tailwind config for entire project 158 | - `tsconfig` - shared tsconfig for entire project 159 | - `ui` - here's a function to merge your tailwind config with global one, and you can save components here 160 | - `vite-config` - shared vite config for entire project 161 | - `zipper` - By ```pnpm zip``` you can pack ```dist``` folder into ```extension.zip``` inside newly created ```dist-zip``` 162 | - `e2e` - By ```pnpm e2e``` you can run end to end tests of your zipped extension on different browsers 163 | 164 | ### Pages 165 | 166 | - `content` - [content script](https://developer.chrome.com/docs/extensions/mv3/content_scripts/) for chrome 167 | extension (`content_scripts` in manifest.json) 168 | - `content-ui` - [content script](https://developer.chrome.com/docs/extensions/mv3/content_scripts/) for render UI in 169 | user's page (`content_scripts` in manifest.json) 170 | - `content-runtime` - [content runtime script](https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts#functionality) 171 | this can be inject from `popup` like standard `content` 172 | - `devtools` - [devtools](https://developer.chrome.com/docs/extensions/mv3/devtools/#creating) for chrome 173 | extension (`devtools_page` in manifest.json) 174 | - `devtools-panel` - devtools panel for [devtools](pages/devtools/src/index.ts) 175 | - `new-tab` - [new tab](https://developer.chrome.com/docs/extensions/mv3/override/) for chrome 176 | extension (`chrome_url_overrides.newtab` in manifest.json) 177 | - `options` - [options](https://developer.chrome.com/docs/extensions/mv3/options/) for chrome extension (`options_page` 178 | in manifest.json) 179 | - `popup` - [popup](https://developer.chrome.com/docs/extensions/reference/browserAction/) for chrome 180 | extension (`action.default_popup` in 181 | manifest.json) 182 | - `side-panel` - [sidepanel(Chrome 114+)](https://developer.chrome.com/docs/extensions/reference/sidePanel/) for chrome 183 | extension (`side_panel.default_path` in manifest.json) 184 | 185 | ## Community 186 | 187 | To chat with other community members, you can join the [Discord](https://discord.gg/4ERQ6jgV9a) server. 188 | You can ask questions on that server, and you can also help others. 189 | 190 | Also, suggest new features or share any challenges you've faced while developing Chrome extensions! 191 | 192 | ## Reference 193 | 194 | - [Vite Plugin](https://vitejs.dev/guide/api-plugin.html) 195 | - [ChromeExtension](https://developer.chrome.com/docs/extensions/mv3/) 196 | - [Rollup](https://rollupjs.org/guide/en/) 197 | - [Turborepo](https://turbo.build/repo/docs) 198 | - [Rollup-plugin-chrome-extension](https://www.extend-chrome.dev/rollup-plugin) 199 | 200 | ## Star History 201 | 202 | 203 | 204 | 205 | 206 | Star History Chart 207 | 208 | 209 | 210 | ## Contributors 211 | 212 | This Boilerplate is made possible thanks to all of its contributors. 213 | 214 | 215 | All Contributors 216 | 217 | 218 | --- 219 | 220 | ## Special Thanks To 221 | 222 | | JetBrains Logo (Main) logo. | Jackson Hong | 223 | |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 224 | 225 | --- 226 | 227 | Made by [Jonghakseo](https://jonghakseo.github.io/) 228 | --------------------------------------------------------------------------------