Initial commit

This commit is contained in:
Aditya Gupta
2026-06-29 19:29:19 +05:30
committed by GitHub
commit aabb8986aa
292 changed files with 17976 additions and 0 deletions

3
.example.env Normal file
View File

@@ -0,0 +1,3 @@
CEB_EXAMPLE=example_env
CEB_DEV_LOCALE=
CEB_CI=

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
* text=auto eol=lf
*.sh text eol=lf

2
.gitguardian.yaml Normal file
View File

@@ -0,0 +1,2 @@
paths_ignore:
- "pnpm-lock.yaml"

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @Jonghakseo

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: Jonghakseo

75
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
name: Bug Report
description: Create a bug report.
title: "[Bug]: "
labels: [ "bug", "triage" ]
assignees:
- Jonghakseo
body:
- type: textarea
id: to-reproduce
attributes:
label: To Reproduce
description: |
Steps to reproduce the behavior.
Use [Brie](https://go.briehq.com/github?utm_source=CEB) to capture screenshots, errors, and network activity for faster issue resolution.
placeholder: Describe the steps to reproduce the issue.
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: A clear and concise description of what you expected to happen.
placeholder: Describe the expected behavior.
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem.
- type: dropdown
id: desktop-os
attributes:
label: OS
description: Select your operating system.
options: [ Windows, macOS, Linux ]
default: 0
validations:
required: true
- type: dropdown
id: desktop-browser
attributes:
label: Browser
description: Select the browser where the issue occurs(Multiple choice).
multiple: true
options: [ Chrome, Opera, Opera GX, Firefox, Safari, Microsoft Edge, Brave ]
default: 0
validations:
required: true
- type: input
id: desktop-node-version
attributes:
label: Node Version
description: Specify the Node.js version you are using.
placeholder: 22.12.0
- type: input
id: desktop-other-packages
attributes:
label: Other Necessary Packages Version
description: Specify versions of other necessary packages.
placeholder: pnpm-10.10.0
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context about the problem here.
placeholder: Provide additional details if necessary.

7
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
contact_links:
- name: GitHub Community Support
url: https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/discussions
about: Please leave your questions here or on Discord - Link below "Security Bug Bounty"
- name: GitHub Security Bug Bounty
url: https://discord.gg/RhETcmdW
about: Please report security vulnerabilities here(Send DM to Jonghakseo or tell us on chat, you want to report it).

View File

@@ -0,0 +1,39 @@
name: Feature Request
description: Suggest an idea for improvement.
title: "[Feature Request]: "
labels: [ "request-new-feature", "triage" ]
assignees:
- Jonghakseo
body:
- type: textarea
id: problem-description
attributes:
label: Problem Description
description: A clear and concise description of what's the problem. Ex. I'm looking for a way to...
placeholder: Describe the problem or frustration.
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: Proposed Solution
description: A clear and concise description of what you want to happen.
placeholder: Describe the solution you'd like.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: A clear and concise description of any alternative solutions.
placeholder: Describe alternative solutions or features.
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context or screenshots about the feature request here.
placeholder: Provide additional details if necessary.

10
.github/auto_assign.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
addReviewers: true
reviewers:
- Jonghakseo
addAssignees: author
filterLabels:
exclude:
- dependencies

25
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
version: 2
updates:
- package-ecosystem: "npm"
target-branch: 'dev'
directory: "/"
schedule:
interval: "daily"
labels:
- "dependencies"
- "npm"
groups:
dev-dependencies:
dependency-type: development
dependencies:
dependency-type: production
versioning-strategy: increase
- package-ecosystem: "github-actions"
target-branch: 'dev'
directory: "/"
schedule:
interval: "daily"
labels:
- "dependencies"
- "github-actions"

25
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,25 @@
<!-- Note: Please ensure your PR is targeting the `dev` branch -->
<!-- Describe what this PR is for in the title. -->
> `*` denotes required fields
## Priority*
- [ ] High: This PR needs to be merged first, before other tasks.
- [x] Medium: This PR should be merged quickly to prevent conflicts due to common changes. (default)
- [ ] Low: This PR does not affect other tasks, so it can be merged later.
## Purpose of the PR*
<!-- Describe the purpose of the PR. -->
## Changes*
## How to check the feature
<!-- Describe how to check the feature in detail -->
<!-- If there are any visual changes, please attach a screenshot for easy identification. -->
## Reference
<!-- Any helpful information for understanding the PR. -->

2
.github/secret_scanning.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
paths-ignore:
- "../pnpm-lock.yaml"

21
.github/stale.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
# Number of days of inactivity before an Issue or Pull Request becomes stale
daysUntilStale: 90
# Number of days of inactivity before a stale Issue or Pull Request is closed
daysUntilClose: 30
# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
exemptLabels:
- pinned
- security
# Label to use when marking as stale
staleLabel: stale
# Comment to post when marking as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when removing the stale label. Set to `false` to disable
unmarkComment: false
# Comment to post when closing a stale Issue or Pull Request. Set to `false` to disable
closeComment: true
# Limit to only `issues` or `pulls`
only: issues

View File

@@ -0,0 +1,24 @@
name: Make sure new PRs are sent to development
on:
pull_request_target:
types: [ opened ]
jobs:
check-branch:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: Vankka/pr-target-branch-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
target: main
exclude: dev
change-to: dev
comment: |
Your PR's base branch was set to `main`, PRs should be set to target `dev`.
The base branch of this PR has been automatically changed to `dev`, please check that there are no merge conflicts.
already-exists-action: close_this
already-exists-comment: "Closing, because {url} basing on the same branch"

19
.github/workflows/build-zip.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: Build And Upload Extension Zip Via Artifact
on: [ pull_request_target ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: pnpm
- run: pnpm install --frozen-lockfile --prefer-offline
- run: pnpm build
- uses: actions/upload-artifact@v4
with:
path: dist/*

View File

@@ -0,0 +1,42 @@
name: Cancel PR workflows on close
on:
pull_request_target:
types: [ closed ]
jobs:
cancel:
runs-on: ubuntu-latest
permissions:
actions: write
if: ${{ github.event.pull_request.merged == false }}
steps:
- name: Cancel all queued/in_progress workflows for this PR
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
owner="${GITHUB_REPOSITORY%%/*}"
repo="${GITHUB_REPOSITORY##*/}"
branch="$BRANCH"
token="${{ secrets.GITHUB_TOKEN }}"
runs=$(curl -s -H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $token" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/$owner/$repo/actions/runs?branch=$branch&status=queued" | jq -r '.workflow_runs[].id')
runs+=" "
runs+=$(curl -s -H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $token" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/$owner/$repo/actions/runs?branch=$branch&status=in_progress" | jq -r '.workflow_runs[].id')
for run_id in $runs; do
if [ -n "$run_id" ]; then
echo "Cancelling workflow run: $run_id"
curl -s -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $token" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/$owner/$repo/actions/runs/$run_id/cancel"
fi
done

40
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: "CodeQL Advanced"
on:
push:
branches: [ "main", "dev" ]
pull_request:
branches: [ "main", "dev" ]
schedule:
- cron: '40 18 * * 0'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
security-events: write
packages: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

View File

@@ -0,0 +1,30 @@
name: Dependabot Pull Request Approve and Merge
on: pull_request_target
permissions:
pull-requests: write
contents: write
jobs:
dependabot:
runs-on: ubuntu-latest
if: ${{ github.actor == 'dependabot[bot]' }}
steps:
- name: Dependabot metadata
id: dependabot-metadata
uses: dependabot/fetch-metadata@v2
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Approve a PR
run: gh pr review --approve "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Enable auto-merge for Dependabot PRs
if: ${{ steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major' }}
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

71
.github/workflows/e2e-modular.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: Modular E2E Tests Matrix
on: [ pull_request_target ]
permissions:
contents: read
env:
ALL_SCENARIOS: '["content", "content-ui", "content-runtime", "background", "new-tab", "popup", "devtools", "side-panel", "options"]'
jobs:
e2e-tests:
name: '${{ matrix.browser }} | Scenario: ${{ matrix.scenario }}'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: ['chrome', 'firefox']
scenario: ['content', 'content-ui', 'content-runtime', 'background', 'new-tab', 'popup', 'devtools', 'side-panel', 'options']
env:
CEB_CI: true
CURRENT_SCENARIO: ${{ matrix.scenario }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: pnpm
- run: pnpm install --frozen-lockfile --prefer-offline
- name: Configure modules | scenario "${{ matrix.scenario }}"
run: |
echo "Deleting all features except '${{ matrix.scenario }}'"
pnpm module-manager --de ${{ matrix.scenario }} tests
- name: Run E2E tests (${{ matrix.browser }})
run: pnpm e2e${{ matrix.browser == 'firefox' && ':firefox' || '' }}
- name: Restore one random feature and re-run E2E tests
run: |
echo "ALL_SCENARIOS=$ALL_SCENARIOS"
echo "CURRENT_SCENARIO=$CURRENT_SCENARIO"
scenarios=($(echo "$ALL_SCENARIOS" | jq -r '.[]'))
# Filter out current scenario
filtered_scenarios=()
for scenario in "${scenarios[@]}"; do
if [ "$scenario" != "$CURRENT_SCENARIO" ]; then
filtered_scenarios+=("$scenario")
fi
done
if [ ${#filtered_scenarios[@]} -eq 0 ]; then
echo "::warning::No other scenario available, skipping restoration."
exit 0
fi
random_feature="${filtered_scenarios[$RANDOM % ${#filtered_scenarios[@]}]}"
echo "Restoring feature: $random_feature"
pnpm module-manager -r "$random_feature"
- name: Run again E2E tests (${{ matrix.browser }})
run: pnpm ${{ matrix.browser == 'chrome' && 'e2e' || 'e2e:firefox' }}

25
.github/workflows/e2e.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Run E2E Tests
on: [ pull_request_target ]
permissions:
contents: read
jobs:
e2e:
name: E2E tests on ${{ matrix.browser }}
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chrome, firefox]
env:
CEB_CI: true
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: pnpm
- run: pnpm install --frozen-lockfile --prefer-offline
- run: pnpm ${{ matrix.browser == 'chrome' && 'e2e' || 'e2e:firefox' }}

16
.github/workflows/greetings.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
name: Greetings
on: [ pull_request_target, issues ]
jobs:
greeting:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: 'Thank you for your contribution. We will check and reply to you as soon as possible.'
pr-message: 'Thank you for your contribution. We will check and reply to you as soon as possible.'

17
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Lint Check
on: [ pull_request_target ]
jobs:
eslint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: pnpm
- run: pnpm install --frozen-lockfile --prefer-offline
- run: pnpm lint

25
.github/workflows/prettier.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Formating validation
on: [ pull_request_target ]
jobs:
prettier:
name: Prettier Check
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Run Prettier
id: prettier-run
uses: rutajdash/prettier-cli-action@v1.0.2
with:
config_path: ./.prettierrc
file_pattern: "*.{js,jsx,ts,tsx,json}"
- name: Prettier Output
if: ${{ failure() }}
shell: bash
run: |
echo "The following files aren't formatted properly:"
echo "${{steps.prettier-run.outputs.prettier_output}}"

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# dependencies
**/node_modules
# testing
**/coverage
# build
**/dist
**/build
**/dist-zip
chrome-extension/manifest.js
chrome-extension/pre-build.tsconfig.tsbuildinfo
tsconfig.tsbuildinfo
# env
**/.env.*
**/.env
# etc
.DS_Store
.idea
**/.turbo
# compiled
**/tailwind-output.css

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
pnpm dlx lint-staged --allow-empty

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
public-hoist-pattern[]=@testing-library/dom
engine-strict=true

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.15.1

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
dist
*.md

12
.prettierrc Normal file
View File

@@ -0,0 +1,12 @@
{
"trailingComma": "all",
"semi": true,
"singleQuote": true,
"arrowParens": "avoid",
"printWidth": 120,
"bracketSameLine": true,
"htmlWhitespaceSensitivity": "strict",
"plugins": [
"prettier-plugin-tailwindcss"
]
}

21
LICENSE Normal file
View File

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

275
README.md Executable file
View File

@@ -0,0 +1,275 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/user-attachments/assets/99cb6303-64e4-4bed-bf3f-35735353e6de" />
<source media="(prefers-color-scheme: light)" srcset="https://github.com/user-attachments/assets/a5dbf71c-c509-4c4f-80f4-be88a1943b0a" />
<img alt="Logo" src="https://github.com/user-attachments/assets/99cb6303-64e4-4bed-bf3f-35735353e6de" />
</picture>
![](https://img.shields.io/badge/React-61DAFB?style=flat-square&logo=react&logoColor=black)
![](https://img.shields.io/badge/Typescript-3178C6?style=flat-square&logo=typescript&logoColor=white)
![](https://badges.aleen42.com/src/vitejs.svg)
![GitHub action badge](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/actions/workflows/build-zip.yml/badge.svg)
![GitHub action badge](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/actions/workflows/lint.yml/badge.svg)
<a href="https://discord.gg/4ERQ6jgV9a" target="_blank"><img src="https://discord.com/api/guilds/1263404974830915637/widget.png"/></a>
> This boilerplate
> has [Legacy version](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/tree/legacy)
</div>
> [!NOTE]
> This project is listed in the [Awesome Vite](https://github.com/vitejs/awesome-vite)
> [!TIP]
> Share storage state between all pages
>
> https://github.com/user-attachments/assets/3b8e189f-6443-490e-a455-4f9570267f8c
## Table of Contents
- [Intro](#intro)
- [Features](#features)
- [Structure](#structure)
- [ChromeExtension](#structure-chrome-extension)
- [Packages](#structure-packages)
- [Pages](#structure-pages)
- [Installation](#installation)
- [Chrome](#installation-chrome)
- [Firefox](#installation-firefox)
- [Install dependency](#install-dependency)
- [For root](#install-dependency-for-root)
- [For module](#install-dependency-for-module)
- [Environment variables](#env-variables)
- [Add new](#env-variables-new)
- [Set via CLI](#env-variables-cli-set)
- [Troubleshooting](#troubleshooting)
- [Hot module reload seems to have frozen](#hot-module-reload-seems-to-have-frozen)
- [Imports not resolving correctly](#imports-not-resolving-correctly)
- [Community](#community)
- [Debugging](#debugging)
- [Reference](#reference)
- [Star History](#star-history)
- [Contributors](#contributors)
## Intro
This boilerplate helps you create Chrome/Firefox extensions using React and Typescript. It improves
the build speed and development experience by using Vite and Turborepo.
## Features
- [React](https://reactjs.org/)
- [TypeScript](https://www.typescriptlang.org/)
- [Tailwindcss](https://tailwindcss.com/)
- [Vite](https://vitejs.dev/) with [Rollup](https://rollupjs.org/)
- [Turborepo](https://turbo.build/repo)
- [Prettier](https://prettier.io/)
- [ESLint](https://eslint.org/)
- [Chrome Extensions Manifest Version 3](https://developer.chrome.com/docs/extensions/mv3/intro/)
- [Custom i18n package](/packages/i18n/)
- [Custom HMR (Hot Module Rebuild) plugin](/packages/hmr)
- [End-to-end testing with WebdriverIO](https://webdriver.io/)
## Installation
1. Clone this repository.( ```git clone https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite``` )
2. Ensure your node version is >= than in `.nvmrc` file, recommend to use [nvm](https://github.com/nvm-sh/nvm?tab=readme-ov-file#intro)
3. Edit `/packages/i18n/locales/`{your locale(s)}/`messages.json`
4. In the objects `extensionDescription` and `extensionName`, change the `message` fields (leave `description` alone)
5. Install pnpm globally: `npm install -g pnpm`
6. Run `pnpm install`
7. Check if you have that configuration in your IDE/Editor:
- <b>VS Code</b>:
- Installed [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)
- Installed [Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
- Enabled `Typescript Workbench version` in settings:
- CTRL + SHIFT + P -> Search: `Typescript: Select Typescript version...` -> `Use Workbench version`
- [Read more](https://code.visualstudio.com/docs/languages/typescript#_using-newer-typescript-versions)
- Optional, for imports to work correctly in WSL, you might need to install the [Remote - WSL](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-wsl) extension and connect to WSL remotely from VS Code. See overview section in the extension page for more information.
- <b>WebStorm</b>:
- Configured [ESLint](https://www.jetbrains.com/help/webstorm/eslint.html#ws_eslint_configure_run_eslint_on_save)
- Configured [Prettier](https://prettier.io/docs/en/webstorm.html)
- Optional, but useful `File | Settings | Tools | Actions on Save`\
-> `Optimize imports` and `Reformat code`
8. Run `pnpm update-version <version>` for change the `version` to the desired version of your extension.
> [!IMPORTANT]
> On Windows, make sure you have WSL enabled and Linux distribution (e.g. Ubuntu) installed on WSL.
>
> [Installation Guide](https://learn.microsoft.com/en-us/windows/wsl/install)
<b>Then, depending on the target browser:</b>
### For Chrome: <a name="installation-chrome"></a>
1. Run:
- Dev: `pnpm dev` (on Windows, you should run as administrator;
see [issue#456](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/issues/456))
- Prod: `pnpm build`
2. Open in browser - `chrome://extensions`
3. Check - <kbd>Developer mode</kbd>
4. Click - <kbd>Load unpacked</kbd> in the upper left corner
5. Select the `dist` directory from the boilerplate project
### For Firefox: <a name="installation-firefox"></a>
1. Run:
- Dev: `pnpm dev:firefox`
- Prod: `pnpm build:firefox`
2. Open in browser - `about:debugging#/runtime/this-firefox`
3. Click - <kbd>Load Temporary Add-on...</kbd> in the upper right corner
4. Select the `./dist/manifest.json` file from the boilerplate project
> [!NOTE]
> In Firefox, you load add-ons in temporary mode. That means they'll disappear after each browser close. You have to
> load the add-on on every browser launch.
## Install dependency for turborepo: <a name="install-dependency"></a>
### For root: <a name="install-dependency-for-root"></a>
1. Run `pnpm i <package> -w`
### For module: <a name="install-dependency-for-module"></a>
1. Run `pnpm i <package> -F <module name>`
`package` - Name of the package you want to install e.g. `nodemon` \
`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
## How do I disable modules I'm not using?
[Read here](packages/module-manager/README.md)
## Environment variables
Read: [Env Documentation](packages/env/README.md)
## Boilerplate structure <a name="structure"></a>
### Chrome extension <a name="structure-chrome-extension"></a>
The extension lives in the `chrome-extension` directory and includes the following files:
- [`manifest.ts`](chrome-extension/manifest.ts) - script that outputs the `manifest.json`
- [`src/background`](chrome-extension/src/background) - [background script](https://developer.chrome.com/docs/extensions/mv3/background_pages/)
(`background.service_worker` in manifest.json)
- [`public`](chrome-extension/public/) - icons referenced in the manifest; content CSS for user's page injection
> [!IMPORTANT]
> To facilitate development, the boilerplate is configured to "Read and change all your data on all websites".
> In production, it's best practice to limit the premissions to only the strictly necessary websites. See
> [Declaring permissions](https://developer.chrome.com/docs/extensions/develop/concepts/declare-permissions)
> and edit `manifest.js` accordingly.
### Pages <a name="structure-pages"></a>
Code that is transpiled to be part of the extension lives in the [pages](pages) directory.
- [`content`](pages/content) - Scripts injected into specified pages (You can see it in console)
- [`content-ui`](pages/content-ui) - React Components injected into specified pages (You can see it at the very bottom of pages)
- [`content-runtime`](pages/content-runtime/src/) - [injected content scripts](https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts#functionality)
This can be injected from e.g. `popup` like standard `content`
- [`devtools`](pages/devtools/) - [extend the browser DevTools](https://developer.chrome.com/docs/extensions/how-to/devtools/extend-devtools#creating)
(`devtools_page` in manifest.json)
- [`devtools-panel`](pages/devtools-panel/) - [DevTools panel](https://developer.chrome.com/docs/extensions/reference/api/devtools/panels)
for [devtools](pages/devtools/src/index.ts)
- [`new-tab`](pages/new-tab/) - [override the default New Tab page](https://developer.chrome.com/docs/extensions/develop/ui/override-chrome-pages)
(`chrome_url_overrides.newtab` in manifest.json)
- [`options`](pages/options/) - [options page](https://developer.chrome.com/docs/extensions/develop/ui/options-page)
(`options_page` in manifest.json)
- [`popup`](pages/popup/) - [popup](https://developer.chrome.com/docs/extensions/reference/api/action#popup) shown when
clicking the extension in the toolbar
(`action.default_popup` in manifest.json)
- [`side-panel`](pages/side-panel/) - [sidepanel (Chrome 114+)](https://developer.chrome.com/docs/extensions/reference/api/sidePanel)
(`side_panel.default_path` in manifest.json)
### Packages <a name="structure-packages"></a>
Some shared packages:
- `dev-utils` - utilities for Chrome extension development (manifest-parser, logger)
- `env` - exports object which contain all environment variables from `.env` and dynamically declared
- `hmr` - custom HMR plugin for Vite, injection script for reload/refresh, HMR dev-server
- `i18n` - custom internationalization package; provides i18n function with type safety and other validation
- `shared` - shared code for the entire project (types, constants, custom hooks, components etc.)
- `storage` - helpers for easier integration with [storage](https://developer.chrome.com/docs/extensions/reference/api/storage), e.g. local/session storages
- `tailwind-config` - shared Tailwind config for entire project
- `tsconfig` - shared tsconfig for the entire project
- `ui` - function to merge your Tailwind config with the global one; you can save components here
- `vite-config` - shared Vite config for the entire project
Other useful packages:
- `zipper` - run `pnpm zip` to pack the `dist` folder into `extension-YYYYMMDD-HHmmss.zip` inside the newly created
`dist-zip`
- `module-manager` - run `pnpm module-manager` to enable/disable modules
- `e2e` - run `pnpm e2e` for end-to-end tests of your zipped extension on different browsers
## Troubleshooting
### Hot module reload seems to have frozen
If saving source files doesn't cause the extension HMR code to trigger a reload of the browser page, try this:
1. Ctrl+C the development server and restart it (`pnpm run dev`)
2. If you get a [`grpc` error](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/issues/612),
[kill the
`turbo` process](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/issues/612#issuecomment-2518982339)
and run `pnpm dev` again.
### Imports not resolving correctly
If you are using WSL and imports are not resolving correctly, ensure that you have connected VS Code to WSL remotely using the [Remote - WSL](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-wsl) extension.
## Community
To chat with other community members, you can join the [Discord](https://discord.gg/4ERQ6jgV9a) server.
You can ask questions on that server, and you can also help others.
Also, suggest new features or share any challenges you've faced while developing Chrome extensions!
## Debugging
If you're debugging one, you can use [Brie](https://go.briehq.com/github?utm_source=CEB) lets you capture screenshots, errors, and network activity, making it easier for us to help.
## Reference
- [Chrome Extensions](https://developer.chrome.com/docs/extensions)
- [Vite Plugin](https://vitejs.dev/guide/api-plugin.html)
- [Rollup](https://rollupjs.org/guide/en/)
- [Turborepo](https://turbo.build/repo/docs)
- [Rollup-plugin-chrome-extension](https://www.extend-chrome.dev/rollup-plugin)
## Star History <a name="star-history"></a>
<a href="https://star-history.com/#Jonghakseo/chrome-extension-boilerplate-react-vite&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Jonghakseo/chrome-extension-boilerplate-react-vite&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Jonghakseo/chrome-extension-boilerplate-react-vite&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Jonghakseo/chrome-extension-boilerplate-react-vite&type=Date" />
</picture>
</a>
## Contributors <a name="contributors"></a>
This Boilerplate is made possible thanks to all of its contributors.
<a href="https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/graphs/contributors">
<img width="500px" src="https://contrib.rocks/image?repo=Jonghakseo/chrome-extension-boilerplate-react-vite" alt="All Contributors"/>
</a>
---
## Special Thanks To
| <a href="https://jb.gg/OpenSourceSupport"><img width="40" src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo (Main) logo."></a> | <a href="https://www.linkedin.com/in/j-acks0n"><img width="40" style="border-radius:50%" src='https://avatars.githubusercontent.com/u/23139754' alt='Jackson Hong'/></a> |
|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
---
Made by [Jonghakseo](https://jonghakseo.github.io/)

View File

@@ -0,0 +1,8 @@
For update package version in all ```package.json``` files use this command in root:
FOR WINDOWS YOU NEED TO USE E.G ```GIT BASH``` CONSOLE OR OTHER WHICH SUPPORT UNIX COMMANDS
```bash
pnpm update-version <new_version>
```
If script was run successfully you will see ```Updated versions to <new_version>```

8
bash-scripts/copy_env.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
# Check if .env does not exist and .example.env exists
if [ ! -f ".env" ] && [ -f ".example.env" ]; then
# Copy .example.env to .env
cp .example.env .env
echo ".example.env has been copied to .env"
fi

View File

@@ -0,0 +1,91 @@
#!/bin/bash
# Default values
CLI_CEB_DEV=false
CLI_CEB_FIREFOX=false
validate_is_boolean() {
if [[ "$1" != "true" && "$1" != "false" ]]; then
echo "Invalid value for <$2>. Please use 'true' or 'false'."
exit 1
fi
}
# Validate if a key starts with CLI_CEB_ or CEB_
validate_key() {
local key="$1"
local is_editable_section="${2:-false}"
if [[ -n "$key" && ! "$key" =~ ^# ]]; then
if [[ "$is_editable_section" == true && ! "$key" =~ ^CEB_ ]]; then
echo "Invalid key: <$key>. All keys in the editable section must start with 'CEB_'."
exit 1
elif [[ "$is_editable_section" == false && ! "$key" =~ ^CLI_CEB_ ]]; then
echo "Invalid key: <$key>. All CLI keys must start with 'CLI_CEB_'."
exit 1
fi
fi
}
parse_arguments() {
for arg in "$@"
do
key="${arg%%=*}"
value="${arg#*=}"
validate_key "$key"
case $key in
CLI_CEB_DEV)
CLI_CEB_DEV="$value"
validate_is_boolean "$CLI_CEB_DEV" "CLI_CEB_DEV"
;;
CLI_CEB_FIREFOX)
CLI_CEB_FIREFOX="$value"
validate_is_boolean "$CLI_CEB_FIREFOX" "CLI_CEB_FIREFOX"
;;
*)
cli_values+=("$key=$value")
;;
esac
done
}
# Validate keys in .env file
validate_env_keys() {
editable_section_starts=false
while IFS= read -r line; do
key="${line%%=*}"
if [[ "$key" =~ ^CLI_CEB_ ]]; then
editable_section_starts=true
elif $editable_section_starts; then
validate_key "$key" true
fi
done < .env
}
create_new_file() {
temp_file=$(mktemp)
{
echo "# THOSE VALUES ARE EDITABLE ONLY VIA CLI"
echo "CLI_CEB_DEV=$CLI_CEB_DEV"
echo "CLI_CEB_FIREFOX=$CLI_CEB_FIREFOX"
for value in "${cli_values[@]}"; do
echo "$value"
done
echo ""
echo "# THOSE VALUES ARE EDITABLE"
# Copy existing env values, without CLI section
grep -E '^CEB_' .env
} > "$temp_file"
mv "$temp_file" .env
}
# Main script execution
parse_arguments "$@"
validate_env_keys
create_new_file

View File

@@ -0,0 +1,17 @@
#!/bin/bash
# Usage: ./update_version.sh <new_version>
# FORMAT IS <0.0.0>
if [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
find . -name 'package.json' -not -path '*/node_modules/*' -exec bash -c '
# Parse the version from package.json
current_version=$(grep -o "\"version\": \"[^\"]*" "$0" | cut -d"\"" -f4)
# Update the version
perl -i -pe"s/$current_version/'$1'/" "$0"
' {} \;
echo "Updated versions to $1";
else
echo "Version format <$1> isn't correct, proper format is <0.0.0>";
fi

View File

@@ -0,0 +1,84 @@
import { readFileSync } from 'node:fs';
import type { ManifestType } from '@extension/shared';
const packageJson = JSON.parse(readFileSync('./package.json', 'utf8'));
/**
* @prop default_locale
* if you want to support multiple languages, you can use the following reference
* https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Internationalization
*
* @prop browser_specific_settings
* Must be unique to your extension to upload to addons.mozilla.org
* (you can delete if you only want a chrome extension)
*
* @prop permissions
* Firefox doesn't support sidePanel (It will be deleted in manifest parser)
*
* @prop content_scripts
* css: ['content.css'], // public folder
*/
const manifest = {
manifest_version: 3,
default_locale: 'en',
name: '__MSG_extensionName__',
browser_specific_settings: {
gecko: {
id: 'example@example.com',
strict_min_version: '109.0',
},
},
version: packageJson.version,
description: '__MSG_extensionDescription__',
host_permissions: ['<all_urls>'],
permissions: ['storage', 'scripting', 'tabs', 'notifications', 'sidePanel'],
options_page: 'options/index.html',
background: {
service_worker: 'background.js',
type: 'module',
},
action: {
default_popup: 'popup/index.html',
default_icon: 'icon-34.png',
},
chrome_url_overrides: {
newtab: 'new-tab/index.html',
},
icons: {
'128': 'icon-128.png',
},
content_scripts: [
{
matches: ['http://*/*', 'https://*/*', '<all_urls>'],
js: ['content/all.iife.js'],
},
{
matches: ['https://example.com/*'],
js: ['content/example.iife.js'],
},
{
matches: ['http://*/*', 'https://*/*', '<all_urls>'],
js: ['content-ui/all.iife.js'],
},
{
matches: ['https://example.com/*'],
js: ['content-ui/example.iife.js'],
},
{
matches: ['http://*/*', 'https://*/*', '<all_urls>'],
css: ['content.css'],
},
],
devtools_page: 'devtools/index.html',
web_accessible_resources: [
{
resources: ['*.js', '*.css', '*.svg', 'icon-128.png', 'icon-34.png'],
matches: ['*://*/*'],
},
],
side_panel: {
default_path: 'side-panel/index.html',
},
} satisfies ManifestType;
export default manifest;

View File

@@ -0,0 +1,36 @@
{
"name": "chrome-extension",
"version": "0.5.0",
"description": "chrome extension - core settings",
"type": "module",
"private": true,
"sideEffects": false,
"scripts": {
"clean:node_modules": "pnpx rimraf node_modules",
"clean:turbo": "rimraf .turbo",
"clean": "pnpm clean:turbo && pnpm clean:node_modules",
"ready": "tsc -b pre-build.tsconfig.json",
"build": "vite build",
"dev": "vite build --mode development",
"test": "vitest run",
"lint": "eslint .",
"lint:fix": "pnpm lint --fix",
"prettier": "prettier . --write --ignore-path ../.prettierignore",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@extension/env": "workspace:*",
"@extension/shared": "workspace:*",
"@extension/storage": "workspace:*",
"webextension-polyfill": "^0.12.0"
},
"devDependencies": {
"@extension/dev-utils": "workspace:*",
"@extension/hmr": "workspace:*",
"@extension/tsconfig": "workspace:*",
"@extension/vite-config": "workspace:*",
"@laynezh/vite-plugin-lib-assets": "^1.1.0",
"magic-string": "^0.30.17",
"ts-loader": "^9.5.2"
}
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./",
"noEmit": false
},
"include": ["manifest.ts"],
"exclude": [""]
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -0,0 +1,9 @@
import 'webextension-polyfill';
import { exampleThemeStorage } from '@extension/storage';
exampleThemeStorage.get().then(theme => {
console.log('theme', theme);
});
console.log('Background loaded');
console.log("Edit 'chrome-extension/src/background/index.ts' and save to reload.");

View File

@@ -0,0 +1,10 @@
{
"extends": "@extension/tsconfig/app",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@src/*": ["src/*"]
}
},
"include": ["src", "utils", "vite.config.mts", "manifest.ts", "../node_modules/@types"]
}

View File

@@ -0,0 +1,83 @@
import { ManifestParser } from '@extension/dev-utils';
import { IS_DEV, IS_FIREFOX } from '@extension/env';
import { colorfulLog } from '@extension/shared';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { platform } from 'node:process';
import { pathToFileURL } from 'node:url';
import type { ManifestType } from '@extension/shared';
import type { PluginOption } from 'vite';
const manifestFile = resolve(import.meta.dirname, '..', '..', 'manifest.js');
const refreshFilePath = resolve(
import.meta.dirname,
'..',
'..',
'..',
'packages',
'hmr',
'dist',
'lib',
'injections',
'refresh.js',
);
const withHMRId = (code: string) => `(function() {let __HMR_ID = 'chrome-extension-hmr';${code}\n})();`;
const getManifestWithCacheBurst = async () => {
const withCacheBurst = (path: string) => `${path}?${Date.now().toString()}`;
/**
* In Windows, import() doesn't work without file:// protocol.
* So, we need to convert path to file:// protocol. (url.pathToFileURL)
*/
if (platform === 'win32') {
return (await import(withCacheBurst(pathToFileURL(manifestFile).href))).default;
} else {
return (await import(withCacheBurst(manifestFile))).default;
}
};
const addRefreshContentScript = (manifest: ManifestType) => {
manifest.content_scripts = manifest.content_scripts || [];
manifest.content_scripts.push({
matches: ['http://*/*', 'https://*/*', '<all_urls>'],
js: ['refresh.js'], // for public's HMR(refresh) support
});
};
export default (config: { outDir: string }): PluginOption => {
const makeManifest = (manifest: ManifestType, to: string) => {
if (!existsSync(to)) {
mkdirSync(to);
}
const manifestPath = resolve(to, 'manifest.json');
if (IS_DEV) {
addRefreshContentScript(manifest);
}
writeFileSync(manifestPath, ManifestParser.convertManifestToString(manifest, IS_FIREFOX));
const refreshFileString = readFileSync(refreshFilePath, 'utf-8');
if (IS_DEV) {
writeFileSync(resolve(to, 'refresh.js'), withHMRId(refreshFileString));
}
colorfulLog(`Manifest file copy complete: ${manifestPath}`, 'success');
};
return {
name: 'make-manifest',
buildStart() {
this.addWatchFile(manifestFile);
},
async writeBundle() {
const outDir = config.outDir;
const manifest = await getManifestWithCacheBurst();
makeManifest(manifest, outDir);
},
};
};

View File

@@ -0,0 +1,52 @@
import { resolve } from 'node:path';
import { defineConfig, type PluginOption } from 'vite';
import libAssetsPlugin from '@laynezh/vite-plugin-lib-assets';
import makeManifestPlugin from './utils/plugins/make-manifest-plugin.js';
import { watchPublicPlugin, watchRebuildPlugin } from '@extension/hmr';
import { watchOption } from '@extension/vite-config';
import env, { IS_DEV, IS_PROD } from '@extension/env';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
const rootDir = resolve(import.meta.dirname);
const srcDir = resolve(rootDir, 'src');
const outDir = resolve(rootDir, '..', 'dist');
export default defineConfig({
define: {
'process.env': env,
},
resolve: {
alias: {
'@root': rootDir,
'@src': srcDir,
'@assets': resolve(srcDir, 'assets'),
},
},
plugins: [
libAssetsPlugin({
outputPath: outDir,
}) as PluginOption,
watchPublicPlugin(),
makeManifestPlugin({ outDir }),
IS_DEV && watchRebuildPlugin({ reload: true, id: 'chrome-extension-hmr' }),
nodePolyfills(),
],
publicDir: resolve(rootDir, 'public'),
build: {
lib: {
name: 'BackgroundScript',
fileName: 'background',
formats: ['es'],
entry: resolve(srcDir, 'background', 'index.ts'),
},
outDir,
emptyOutDir: false,
sourcemap: IS_DEV,
minify: IS_PROD,
reportCompressedSize: IS_PROD,
watch: watchOption,
rollupOptions: {
external: ['chrome'],
},
},
});

105
eslint.config.ts Normal file
View File

@@ -0,0 +1,105 @@
import { fixupConfigRules } from '@eslint/compat';
import { FlatCompat } from '@eslint/eslintrc';
import js from '@eslint/js';
import { flatConfigs as importXFlatConfig } from 'eslint-plugin-import-x';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import reactPlugin from 'eslint-plugin-react';
import { browser, es2020, node } from 'globals';
import { config, configs as tsConfigs, parser as tsParser } from 'typescript-eslint';
import type { FixupConfigArray } from '@eslint/compat';
export default config(
// Shared configs
js.configs.recommended,
...tsConfigs.recommended,
jsxA11y.flatConfigs.recommended,
importXFlatConfig.recommended,
importXFlatConfig.typescript,
eslintPluginPrettierRecommended,
...fixupConfigRules(new FlatCompat().extends('plugin:react-hooks/recommended') as FixupConfigArray),
{
files: ['**/*.{ts,tsx}'],
...reactPlugin.configs.flat.recommended,
...reactPlugin.configs.flat['jsx-runtime'],
},
// Custom config
{
ignores: ['**/build/**', '**/dist/**', '**/node_modules/**', 'chrome-extension/manifest.js'],
},
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
ecmaFeatures: { jsx: true },
projectService: true,
},
globals: {
...browser,
...es2020,
...node,
chrome: 'readonly',
},
},
settings: {
react: {
version: 'detect',
},
},
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'prefer-const': 'error',
'no-var': 'error',
'func-style': ['error', 'expression', { allowArrowFunctions: true }],
'no-restricted-imports': [
'error',
{
name: 'type-fest',
message: 'Please import from `@extension/shared` instead of `type-fest`.',
},
],
'arrow-body-style': ['error', 'as-needed'],
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/consistent-type-exports': 'error',
'import-x/order': [
'error',
{
'newlines-between': 'never',
alphabetize: { order: 'asc', caseInsensitive: true },
groups: ['index', 'sibling', 'parent', 'internal', 'external', 'builtin', 'object', 'type'],
pathGroups: [
{
pattern: '@*/**',
group: 'internal',
position: 'before',
},
],
pathGroupsExcludedImportTypes: ['type'],
},
],
'import-x/no-unresolved': 'off',
'import-x/no-named-as-default': 'error',
'import-x/no-named-as-default-member': 'error',
'import-x/newline-after-import': 'error',
'import-x/no-deprecated': 'error',
'import-x/no-duplicates': ['error', { considerQueryString: true, 'prefer-inline': false }],
'import-x/consistent-type-specifier-style': 'error',
'import-x/exports-last': 'error',
'import-x/first': 'error',
},
linterOptions: {
reportUnusedDisableDirectives: 'error',
},
},
// Overrides Rules
{
files: ['**/packages/shared/**/*.ts'],
rules: {
'no-restricted-imports': 'off',
},
},
);

97
package.json Normal file
View File

@@ -0,0 +1,97 @@
{
"name": "chrome-extension-boilerplate-react-vite",
"version": "0.5.0",
"description": "chrome extension boilerplate",
"license": "MIT",
"private": true,
"sideEffects": false,
"repository": {
"type": "git",
"url": "https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite.git"
},
"type": "module",
"scripts": {
"clean:bundle": "rimraf dist && turbo clean:bundle",
"clean:node_modules": "pnpx rimraf node_modules && pnpx turbo clean:node_modules",
"clean:turbo": "rimraf .turbo && turbo clean:turbo",
"clean": "pnpm clean:bundle && pnpm clean:turbo && pnpm clean:node_modules",
"clean:install": "pnpm clean:node_modules && pnpm install --frozen-lockfile",
"type-check": "turbo type-check",
"base-build": "pnpm clean:bundle && turbo build",
"build": "pnpm set-global-env && pnpm base-build",
"build:firefox": "pnpm set-global-env CLI_CEB_FIREFOX=true && pnpm base-build",
"base-dev": "pnpm clean:bundle && turbo ready && turbo watch dev",
"dev": "pnpm set-global-env CLI_CEB_DEV=true && pnpm base-dev",
"dev:firefox": "pnpm set-global-env CLI_CEB_DEV=true CLI_CEB_FIREFOX=true && pnpm base-dev",
"zip": "pnpm build && pnpm -F zipper zip",
"zip:firefox": "pnpm build:firefox && pnpm -F zipper zip",
"e2e": "pnpm zip && turbo e2e",
"e2e:firefox": "pnpm zip:firefox && turbo e2e",
"lint": "turbo lint --continue",
"lint:fix": "turbo lint:fix --continue",
"format": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache",
"prepare": "husky",
"update-version": "bash bash-scripts/update_version.sh",
"copy-env": "bash bash-scripts/copy_env.sh",
"set-global-env": "bash bash-scripts/set_global_env.sh",
"module-manager": "pnpm -F module-manager start",
"postinstall": "pnpm copy-env"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@eslint/compat": "^1.2.9",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.27.0",
"@types/chrome": "^0.0.323",
"@types/eslint-plugin-jsx-a11y": "^6.10.0",
"@types/node": "^22.15.21",
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",
"@typescript-eslint/parser": "^8.32.1",
"autoprefixer": "^10.4.21",
"cross-env": "^7.0.3",
"deepmerge": "^4.3.1",
"eslint": "^9.27.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.3.5",
"eslint-plugin-import-x": "^4.12.2",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.4.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-tailwindcss": "^3.18.0",
"fast-glob": "^3.3.3",
"fflate": "^0.8.2",
"globals": "^16.1.0",
"husky": "^9.1.7",
"jiti": "^2.4.2",
"lint-staged": "^16.0.0",
"postcss": "^8.5.3",
"postcss-load-config": "^6.0.1",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"rimraf": "^6.0.1",
"run-script-os": "^1.1.6",
"tailwindcss": "^3.4.17",
"tslib": "^2.8.1",
"tsx": "^4.19.4",
"turbo": "^2.5.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.1",
"vite": "^6.3.6",
"vite-plugin-node-polyfills": "^0.23.0"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,json}": [
"prettier --write",
"eslint --fix"
]
},
"packageManager": "pnpm@10.11.0",
"engines": {
"node": ">=22.15.1"
}
}

View File

@@ -0,0 +1,16 @@
# Shared Package
This package contains code which could helps you to develop.
To use the code in the package, you need to add the following to the package.json file.
```json
{
"devDependencies": {
"@extension/dev-utils": "workspace:*"
}
}
```
## Additional Information
`lib/shared-types.ts` contains the types from [type-fest](https://github.com/sindresorhus/type-fest?tab=readme-ov-file#install).

View File

@@ -0,0 +1 @@
export * from './lib/index.js';

View File

@@ -0,0 +1,2 @@
export * from './stream-file-to-zip.js';
export * from './manifest-parser/index.js';

View File

@@ -0,0 +1,39 @@
import type { IManifestParser } from './types.js';
import type { ManifestType } from '@extension/shared';
const convertToFirefoxCompatibleManifest = (manifest: ManifestType) => {
const manifestCopy = {
...manifest,
} as { [key: string]: unknown };
if (manifest.background?.service_worker) {
manifestCopy.background = {
scripts: [manifest.background.service_worker],
type: 'module',
};
}
if (manifest.options_page) {
manifestCopy.options_ui = {
page: manifest.options_page,
browser_style: false,
};
}
manifestCopy.content_security_policy = {
extension_pages: "script-src 'self'; object-src 'self'",
};
manifestCopy.permissions = (manifestCopy.permissions as string[]).filter(value => value !== 'sidePanel');
delete manifestCopy.options_page;
delete manifestCopy.side_panel;
return manifestCopy as ManifestType;
};
export const ManifestParserImpl: IManifestParser = {
convertManifestToString: (manifest, isFirefox) => {
if (isFirefox) {
manifest = convertToFirefoxCompatibleManifest(manifest);
}
return JSON.stringify(manifest, null, 2);
},
};

View File

@@ -0,0 +1,4 @@
import { ManifestParserImpl } from './impl.js';
export type * from './types.js';
export const ManifestParser = ManifestParserImpl;

View File

@@ -0,0 +1,5 @@
import type { ManifestType } from '@extension/shared';
export interface IManifestParser {
convertManifestToString: (manifest: ManifestType, isFirefox: boolean) => string;
}

View File

@@ -0,0 +1,24 @@
import { AsyncZipDeflate } from 'fflate';
import { createReadStream } from 'node:fs';
import type { Zip } from 'fflate';
export const streamFileToZip = (
absPath: string,
relPath: string,
zip: Zip,
onAbort: () => void,
onError: (error: Error) => void,
): void => {
const data = new AsyncZipDeflate(relPath, { level: 1 });
void zip.add(data);
createReadStream(absPath)
.on('data', (chunk: string | Buffer) =>
typeof chunk === 'string' ? data.push(Buffer.from(chunk), false) : data.push(chunk, false),
)
.on('end', () => data.push(new Uint8Array(0), true))
.on('error', error => {
onAbort();
onError(error);
});
};

View File

@@ -0,0 +1,29 @@
{
"name": "@extension/dev-utils",
"version": "0.5.0",
"description": "chrome extension - dev utils",
"type": "module",
"private": true,
"sideEffects": false,
"files": [
"dist/**"
],
"types": "index.mts",
"main": "dist/index.mjs",
"scripts": {
"clean:bundle": "rimraf dist",
"clean:node_modules": "pnpx rimraf node_modules",
"clean:turbo": "rimraf .turbo",
"clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo",
"ready": "tsc -b",
"lint": "eslint .",
"lint:fix": "pnpm lint --fix",
"format": "prettier . --write --ignore-path ../../.prettierignore",
"type-check": "tsc --noEmit",
"postinstall": "pnpm ready"
},
"devDependencies": {
"@extension/tsconfig": "workspace:*",
"@extension/shared": "workspace:*"
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@extension/tsconfig/module",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist"
},
"include": ["index.mts", "lib"]
}

31
packages/env/README.md vendored Normal file
View File

@@ -0,0 +1,31 @@
# Environment Package
This package contains code which creates env values.
To use the code in the package, you need to follow those steps:
1. Add a new record to `.env` (NEED TO CONTAIN `CEB_` PREFIX),
- If you want via cli:
- Add it as argument like: `pnpm set-global-env CLI_CEB_NEXT_VALUE=new_data ...` (NEED TO CONTAIN `CLI_CEB_` PREFIX)
> [!IMPORTANT]
> `CLI_CEB_DEV` and `CLI_CEB_FIREFOX` are `false` by default \
> All CLI values are overwriting in each call, that's mean you'll have access to values from current script run only.
- If you want dynamic variables go to `lib/index.ts` and edit `dynamicEnvValues` object.
2. Use it, for example:
```ts
console.log(process.env['CEB_EXAMPLE']);
```
or
```ts
console.log(process.env.CEB_EXAMPLE);
```
but with first solution, autofill should work for IDE:
![img.png](use-env-example.png)
3. You are also able to import const like `IS_DEV` from `@extension/env` like:
```ts
import { IS_DEV } from '@extension/env';
```
For more look [ENV CONST](lib/const.ts)

9
packages/env/index.mts vendored Normal file
View File

@@ -0,0 +1,9 @@
import { baseEnv, dynamicEnvValues } from './lib/index.js';
import type { EnvType } from './lib/types.js';
export * from './lib/index.js';
export default {
...baseEnv,
...dynamicEnvValues,
} as EnvType;

10
packages/env/lib/config.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
import { config } from '@dotenvx/dotenvx';
export const baseEnv =
config({
path: `${import.meta.dirname}/../../../../.env`,
}).parsed ?? {};
export const dynamicEnvValues = {
CEB_NODE_ENV: baseEnv.CEB_DEV === 'true' ? 'development' : 'production',
} as const;

4
packages/env/lib/const.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
export const IS_DEV = process.env['CLI_CEB_DEV'] === 'true';
export const IS_PROD = !IS_DEV;
export const IS_FIREFOX = process.env['CLI_CEB_FIREFOX'] === 'true';
export const IS_CI = process.env['CEB_CI'] === 'true';

2
packages/env/lib/index.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export * from './config.js';
export * from './const.js';

13
packages/env/lib/types.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
import type { dynamicEnvValues } from './index.js';
interface ICebEnv {
readonly CEB_EXAMPLE: string;
readonly CEB_DEV_LOCALE: string;
}
interface ICebCliEnv {
readonly CLI_CEB_DEV: string;
readonly CLI_CEB_FIREFOX: string;
}
export type EnvType = ICebEnv & ICebCliEnv & typeof dynamicEnvValues;

30
packages/env/package.json vendored Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "@extension/env",
"version": "0.5.0",
"description": "chrome extension - environment variables",
"type": "module",
"private": true,
"sideEffects": false,
"files": [
"dist/**"
],
"types": "index.mts",
"main": "dist/index.mjs",
"scripts": {
"clean:bundle": "rimraf dist",
"clean:node_modules": "pnpx rimraf node_modules",
"clean:turbo": "rimraf .turbo",
"clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo",
"ready": "tsc -b",
"lint": "eslint .",
"lint:fix": "pnpm lint --fix",
"format": "prettier . --write --ignore-path ../../.prettierignore",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@dotenvx/dotenvx": "^1.44.1"
},
"devDependencies": {
"@extension/tsconfig": "workspace:*"
}
}

8
packages/env/tsconfig.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "@extension/tsconfig/module",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist"
},
"include": ["index.mts", "lib"]
}

BIN
packages/env/use-env-example.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

1
packages/hmr/index.mts Normal file
View File

@@ -0,0 +1 @@
export * from './lib/plugins/index.js';

View File

@@ -0,0 +1,6 @@
export const LOCAL_RELOAD_SOCKET_PORT = 8081;
export const LOCAL_RELOAD_SOCKET_URL = `ws://localhost:${LOCAL_RELOAD_SOCKET_PORT}`;
export const DO_UPDATE = 'do_update';
export const DONE_UPDATE = 'done_update';
export const BUILD_COMPLETE = 'build_complete';

View File

@@ -0,0 +1,17 @@
import { DO_UPDATE, DONE_UPDATE, LOCAL_RELOAD_SOCKET_URL } from '../consts.js';
import MessageInterpreter from '../interpreter/index.js';
export default ({ id, onUpdate }: { id: string; onUpdate: () => void }) => {
const ws = new WebSocket(LOCAL_RELOAD_SOCKET_URL);
ws.onopen = () => {
ws.addEventListener('message', event => {
const message = MessageInterpreter.receive(String(event.data));
if (message.type === DO_UPDATE && message.id === id) {
onUpdate();
ws.send(MessageInterpreter.send({ type: DONE_UPDATE }));
}
});
};
};

View File

@@ -0,0 +1,53 @@
import {
BUILD_COMPLETE,
DO_UPDATE,
DONE_UPDATE,
LOCAL_RELOAD_SOCKET_PORT,
LOCAL_RELOAD_SOCKET_URL,
} from '../consts.js';
import MessageInterpreter from '../interpreter/index.js';
import { WebSocketServer } from 'ws';
import type { WebSocket } from 'ws';
const clientsThatNeedToUpdate: Set<WebSocket> = new Set();
(() => {
const wss = new WebSocketServer({ port: LOCAL_RELOAD_SOCKET_PORT });
wss.on('listening', () => {
console.log(`[HMR] Server listening at ${LOCAL_RELOAD_SOCKET_URL}`);
});
wss.on('connection', ws => {
clientsThatNeedToUpdate.add(ws);
ws.addEventListener('close', () => {
clientsThatNeedToUpdate.delete(ws);
});
ws.addEventListener('message', event => {
if (typeof event.data !== 'string') return;
const message = MessageInterpreter.receive(event.data);
if (message.type === DONE_UPDATE) {
ws.close();
}
if (message.type === BUILD_COMPLETE) {
clientsThatNeedToUpdate.forEach((ws: WebSocket) =>
ws.send(MessageInterpreter.send({ type: DO_UPDATE, id: message.id })),
);
}
});
});
wss.on('error', (error: Error & { code: string }) => {
if (error.code === 'EADDRINUSE') {
console.info(`[HMR] Server already running at ${LOCAL_RELOAD_SOCKET_URL}, skipping reload server initialization`);
} else {
console.error(`[HMR] Failed to start server at ${LOCAL_RELOAD_SOCKET_URL}`);
throw error;
}
});
})();

View File

@@ -0,0 +1,33 @@
import initClient from '../initializers/init-client.js';
(() => {
let pendingReload = false;
initClient({
// @ts-expect-error That's because of the dynamic code loading
id: __HMR_ID,
onUpdate: () => {
// disable reload when tab is hidden
if (document.hidden) {
pendingReload = true;
return;
}
reload();
},
});
// reload
const reload = (): void => {
pendingReload = false;
window.location.reload();
};
// reload when tab is visible
const reloadWhenTabIsVisible = (): void => {
if (!document.hidden && pendingReload) {
reload();
}
};
document.addEventListener('visibilitychange', reloadWhenTabIsVisible);
})();

View File

@@ -0,0 +1,13 @@
import initClient from '../initializers/init-client.js';
(() => {
const reload = () => {
chrome.runtime.reload();
};
initClient({
// @ts-expect-error That's because of the dynamic code loading
id: __HMR_ID,
onUpdate: reload,
});
})();

View File

@@ -0,0 +1,6 @@
import type { WebSocketMessageType } from '../types.js';
export default {
send: (message: WebSocketMessageType): string => JSON.stringify(message),
receive: (serializedMessage: string): WebSocketMessageType => JSON.parse(serializedMessage),
};

View File

@@ -0,0 +1,3 @@
export * from './watch-rebuild-plugin.js';
export * from './watch-public-plugin.js';
export * from './make-entry-point-plugin.js';

View File

@@ -0,0 +1,73 @@
import { IS_FIREFOX } from '@extension/env';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { basename, resolve, sep } from 'node:path';
import type { PluginOption } from 'vite';
/**
* Extract content directory from output directory for Firefox
* @param outputDir
*/
const extractContentDir = (outputDir: string) => {
const parts = outputDir.split(sep);
const distIndex = parts.indexOf('dist');
if (distIndex !== -1 && distIndex < parts.length - 1) {
return parts.slice(distIndex + 1);
}
throw new Error('Output directory does not contain "dist"');
};
const safeWriteFileSync = (path: string, data: string) => {
const folder = path.split(sep).slice(0, -1).join(sep);
if (!existsSync(folder)) {
mkdirSync(folder, { recursive: true });
}
writeFileSync(path, data);
};
/**
* Make an entry point file for content script cache busting
*/
export const makeEntryPointPlugin = (): PluginOption => ({
name: 'make-entry-point-plugin',
generateBundle(options, bundle) {
const outputDir = options.dir;
if (!outputDir) {
throw new Error('Output directory not found');
}
for (const module of Object.values(bundle)) {
const fileName = module.fileName;
const newFileName = fileName.replace('.js', '_dev.js');
switch (module.type) {
case 'asset':
if (fileName.endsWith('.map')) {
const originalFileName = fileName.replace('.map', '');
const replacedSource = String(module.source).replaceAll(originalFileName, newFileName);
module.source = '';
safeWriteFileSync(resolve(outputDir, newFileName), replacedSource);
break;
}
break;
case 'chunk': {
safeWriteFileSync(resolve(outputDir, newFileName), module.code);
const newFileNameBase = basename(newFileName);
if (IS_FIREFOX) {
const contentDirectory = extractContentDir(outputDir);
module.code = `import(browser.runtime.getURL("${contentDirectory}/${newFileNameBase}"));`;
} else {
module.code = `import('./${newFileNameBase}');`;
}
break;
}
}
}
},
});

View File

@@ -0,0 +1,13 @@
import fg from 'fast-glob';
import type { PluginOption } from 'vite';
export const watchPublicPlugin = (): PluginOption => ({
name: 'watch-public-plugin',
async buildStart() {
const files = await fg(['public/**/*']);
for (const file of files) {
this.addWatchFile(file);
}
},
});

View File

@@ -0,0 +1,68 @@
import { BUILD_COMPLETE, LOCAL_RELOAD_SOCKET_URL } from '../consts.js';
import MessageInterpreter from '../interpreter/index.js';
import { WebSocket } from 'ws';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { PluginConfigType } from '../types.js';
import type { PluginOption } from 'vite';
const injectionsPath = resolve(import.meta.dirname, '..', 'injections');
const refreshCode = readFileSync(resolve(injectionsPath, 'refresh.js'), 'utf-8');
const reloadCode = readFileSync(resolve(injectionsPath, 'reload.js'), 'utf-8');
export const watchRebuildPlugin = (config: PluginConfigType): PluginOption => {
const { refresh, reload, id: _id, onStart } = config;
const hmrCode = (refresh ? refreshCode : '') + (reload ? reloadCode : '');
let ws: WebSocket | null = null;
const id = _id ?? Math.random().toString(36);
let reconnectTries = 0;
const initializeWebSocket = () => {
ws = new WebSocket(LOCAL_RELOAD_SOCKET_URL);
ws.onopen = () => {
console.log(`[HMR] Connected to dev-server at ${LOCAL_RELOAD_SOCKET_URL}`);
};
ws.onerror = () => {
console.error(`[HMR] Failed to connect server at ${LOCAL_RELOAD_SOCKET_URL}`);
console.warn('Retrying in 3 seconds...');
ws = null;
if (reconnectTries <= 2) {
setTimeout(() => {
reconnectTries++;
initializeWebSocket();
}, 3_000);
} else {
console.error(`[HMR] Cannot establish connection to server at ${LOCAL_RELOAD_SOCKET_URL}`);
}
};
};
return {
name: 'watch-rebuild',
closeBundle() {
onStart?.();
if (!ws) {
initializeWebSocket();
return;
}
/**
* When the build is complete, send a message to the reload server.
* The reload server will send a message to the client to reload or refresh the extension.
*/
ws.send(MessageInterpreter.send({ type: BUILD_COMPLETE, id }));
},
generateBundle(_options, bundle) {
for (const module of Object.values(bundle)) {
if (module.type === 'chunk') {
module.code = `(function() {let __HMR_ID = "${id}";\n` + hmrCode + '\n' + '})();' + '\n' + module.code;
}
}
},
};
};

18
packages/hmr/lib/types.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { BUILD_COMPLETE, DO_UPDATE, DONE_UPDATE } from './consts.js';
type UpdateRequestMessageType = {
type: typeof DO_UPDATE;
id: string;
};
type UpdateCompleteMessageType = { type: typeof DONE_UPDATE };
type BuildCompletionMessageType = { type: typeof BUILD_COMPLETE; id: string };
export type WebSocketMessageType = UpdateCompleteMessageType | UpdateRequestMessageType | BuildCompletionMessageType;
export type PluginConfigType = {
onStart?: () => void;
reload?: boolean;
refresh?: boolean;
id?: string;
};

36
packages/hmr/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "@extension/hmr",
"version": "0.5.0",
"description": "chrome extension - hot module reload/refresh",
"type": "module",
"private": true,
"sideEffects": true,
"files": [
"dist/**"
],
"types": "index.mts",
"main": "dist/index.mjs",
"scripts": {
"clean:bundle": "rimraf dist && pnpx rimraf build",
"clean:node_modules": "pnpx rimraf node_modules",
"clean:turbo": "rimraf .turbo",
"clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo",
"ready": "tsc -b && rollup --config dist/rollup.config.js",
"dev": "tsx lib/initializers/init-reload-server.ts",
"lint": "eslint .",
"lint:fix": "pnpm lint --fix",
"format": "prettier . --write --ignore-path ../../.prettierignore",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@extension/env": "workspace:*",
"@extension/tsconfig": "workspace:*",
"@rollup/plugin-sucrase": "^5.0.2",
"@types/ws": "^8.18.1",
"esbuild": "^0.25.4",
"esm": "^3.2.25",
"rollup": "^4.41.0",
"ts-node": "^10.9.2",
"ws": "^8.18.2"
}
}

View File

@@ -0,0 +1,29 @@
import sucrase from '@rollup/plugin-sucrase';
import type { Plugin, RollupOptions } from 'rollup';
const plugins = [
// @ts-expect-error Because of the lack of calling signature
sucrase({
exclude: ['node_modules/**'],
transforms: ['typescript'],
}),
] satisfies Plugin[];
export default [
{
plugins,
input: 'lib/injections/reload.ts',
output: {
format: 'esm',
file: 'dist/lib/injections/reload.js',
},
},
{
plugins,
input: 'lib/injections/refresh.ts',
output: {
format: 'esm',
file: 'dist/lib/injections/refresh.js',
},
},
] satisfies RollupOptions[];

View File

@@ -0,0 +1,9 @@
{
"extends": "@extension/tsconfig/module",
"compilerOptions": {
"outDir": "dist",
"baseUrl": ".",
"types": ["chrome", "node"]
},
"include": ["index.mts", "rollup.config.ts", "lib"]
}

1
packages/i18n/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
lib/i18n.ts

173
packages/i18n/README.md Normal file
View File

@@ -0,0 +1,173 @@
# I18n Package
This package provides a set of tools to help you internationalize your Chrome Extension.
https://developer.chrome.com/docs/extensions/reference/api/i18n
## Installation
If you want to use the i18n translation function in each pages, you need to add the following to the package.json file.
```json
{
"dependencies": {
"@extension/i18n": "workspace:*"
}
}
```
Then run the following command to install the package.
```bash
pnpm install
```
## Manage translations
You can manage translations in the `locales` directory.
`locales/en/messages.json`
```json
{
"helloWorld": {
"message": "Hello, World!"
}
}
```
`locales/ko/messages.json`
```json
{
"helloWorld": {
"message": "안녕하세요, 여러분!"
}
}
```
## Add a new language
Create folder inside `locales` with name from [languages](https://developer.chrome.com/docs/extensions/reference/api/i18n?hl=pl#support_multiple_languages), which need include `message.json` file.
## Usage
### Translation function
Just import the `t` function and use it to translate the key.
```typescript
import { t } from '@extension/i18n';
console.log(t('loading')); // Loading...
```
```typescript jsx
import { t } from '@extension/i18n';
const Component = () => {
return (
<button>
{t('toggleTheme')} // Toggle Theme
</button>
);
};
```
### Placeholders
If you want to use placeholders, you can use the following format.
> For more information, see the [Message Placeholders](https://developer.chrome.com/docs/extensions/how-to/ui/localization-message-formats#placeholders) section.
`locales/en/messages.json`
```json
{
"greeting": {
"description": "Greeting message",
"message": "Hello, My name is $NAME$",
"placeholders": {
"name": {
"content": "$1",
"example": "John Doe"
}
}
},
"hello": {
"description": "Placeholder example",
"message": "Hello $1"
}
}
```
`locales/ko/messages.json`
```json
{
"greeting": {
"description": "인사 메시지",
"message": "안녕하세요, 제 이름은 $NAME$입니다.",
"placeholders": {
"name": {
"content": "$1",
"example": "서종학"
}
}
},
"hello": {
"description": "Placeholder 예시",
"message": "안녕 $1"
}
}
```
If you want to replace the placeholder, you can pass the value as the second argument.
Function `t` has exactly the same interface as the `chrome.i18n.getMessage` function.
```typescript
import { t } from '@extension/i18n';
console.log(t('greeting', 'John Doe')); // Hello, My name is John Doe
console.log(t('greeting', ['John Doe'])); // Hello, My name is John Doe
console.log(t('hello')); // Hello
console.log(t('hello', 'World')); // Hello World
console.log(t('hello', ['World'])); // Hello World
```
### Locale setting on development
If you want to enforce displaying specific language, you need to set `CEB_DEV_LOCALE` in `.env` file (work only for development).
### Type Safety
When you forget to add a key to all language's `messages.json` files, you will get a Typescript error.
`locales/en/messages.json`
```json
{
"hello": {
"message": "Hello World!"
}
}
```
`locales/ko/messages.json`
```json
{
"helloWorld": {
"message": "안녕하세요, 여러분!"
}
}
```
```typescript
import { t } from '@extension/i18n';
// Error: TS2345: Argument of type "hello" is not assignable to parameter of type
console.log(t('hello'));
```

1
packages/i18n/index.mts Normal file
View File

@@ -0,0 +1 @@
export * from './lib/index.js';

View File

@@ -0,0 +1,62 @@
/**
* @url https://developer.chrome.com/docs/extensions/reference/api/i18n#support_multiple_languages
*/
export const SUPPORTED_LANGUAGES = {
ar: 'Arabic',
am: 'Amharic',
bg: 'Bulgarian',
bn: 'Bengali',
ca: 'Catalan',
cs: 'Czech',
da: 'Danish',
de: 'German',
el: 'Greek',
en: 'English',
en_AU: 'English (Australia)',
en_GB: 'English (Great Britain)',
en_US: 'English (USA)',
es: 'Spanish',
es_419: 'Spanish (Latin America and Caribbean)',
et: 'Estonian',
fa: 'Persian',
fi: 'Finnish',
fil: 'Filipino',
fr: 'French',
gu: 'Gujarati',
he: 'Hebrew',
hi: 'Hindi',
hr: 'Croatian',
hu: 'Hungarian',
id: 'Indonesian',
it: 'Italian',
ja: 'Japanese',
kn: 'Kannada',
ko: 'Korean',
lt: 'Lithuanian',
lv: 'Latvian',
ml: 'Malayalam',
mr: 'Marathi',
ms: 'Malay',
nl: 'Dutch',
no: 'Norwegian',
pl: 'Polish',
pt_BR: 'Portuguese (Brazil)',
pt_PT: 'Portuguese (Portugal)',
ro: 'Romanian',
ru: 'Russian',
sk: 'Slovak',
sl: 'Slovenian',
sr: 'Serbian',
sv: 'Swedish',
sw: 'Swahili',
ta: 'Tamil',
te: 'Telugu',
th: 'Thai',
tr: 'Turkish',
uk: 'Ukrainian',
vi: 'Vietnamese',
zh_CN: 'Chinese (China)',
zh_TW: 'Chinese (Taiwan)',
} as const;
export const I18N_FILE_PATH = './lib/i18n.ts';

View File

@@ -0,0 +1,34 @@
// IT WILL BE ADJUSTED TO YOUR LANGUAGE DURING BUILD TIME, DON'T MOVE BELOW IMPORT TO OTHER LINE
import localeJSON from '../locales/en/messages.json' with { type: 'json' };
import type { I18nValueType, LocalesJSONType } from './types.js';
const translate = (key: keyof LocalesJSONType, substitutions?: string | string[]) => {
const localeValues = localeJSON[key] as I18nValueType;
let message = localeValues.message;
/**
* This is a placeholder replacement logic. But it's not perfect.
* It just imitates the behavior of the Chrome extension i18n API.
* Please check the official document for more information And double-check the behavior on production build.
*
* @url https://developer.chrome.com/docs/extensions/how-to/ui/localization-message-formats#placeholders
*/
if (localeValues.placeholders) {
Object.entries(localeValues.placeholders).forEach(([key, { content }]) => {
if (content) {
message = message.replace(new RegExp(`\\$${key}\\$`, 'gi'), content);
}
});
}
if (!substitutions) {
return message;
} else if (Array.isArray(substitutions)) {
return substitutions.reduce((acc, cur, idx) => acc.replace(`$${idx++}`, cur), message);
}
return message.replace(/\$(\d+)/, substitutions);
};
const removePlaceholder = (message: string) => message.replace(/\$\d+/g, '');
export const t = (...args: Parameters<typeof translate>) => removePlaceholder(translate(...args));

View File

@@ -0,0 +1,3 @@
import type { MessageKeyType } from './types.js';
export const t = (key: MessageKeyType, substitutions?: string | string[]) => chrome.i18n.getMessage(key, substitutions);

View File

@@ -0,0 +1,4 @@
import { t as t_dev_or_prod } from './i18n.js';
import type { t as t_dev } from './i18n-dev.js';
export const t = t_dev_or_prod as unknown as typeof t_dev;

View File

@@ -0,0 +1,22 @@
import setRelatedLocaleImports from './set-related-locale-import.js';
import { IS_DEV } from '@extension/env';
import { cpSync, existsSync, mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
(() => {
const i18nPath = IS_DEV ? 'lib/i18n-dev.ts' : 'lib/i18n-prod.ts';
cpSync(i18nPath, resolve('lib', 'i18n.ts'));
const outDir = resolve(import.meta.dirname, '..', '..', '..', '..', 'dist');
if (!existsSync(outDir)) {
mkdirSync(outDir);
}
const localePath = resolve(outDir, '_locales');
cpSync(resolve('locales'), localePath, { recursive: true });
if (IS_DEV) {
setRelatedLocaleImports();
}
console.log('I18n build complete');
})();

View File

@@ -0,0 +1,38 @@
import { I18N_FILE_PATH } from './consts.js';
import { lstatSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { SupportedLanguagesKeysType, SupportedLanguagesWithoutRegionKeysType } from './types.js';
export default () => {
const locale = Intl.DateTimeFormat().resolvedOptions().locale.replace('-', '_') as SupportedLanguagesKeysType;
const localeWithoutRegion = locale.split('_')[0] as SupportedLanguagesWithoutRegionKeysType;
const localesDir = resolve(import.meta.dirname, '..', '..', 'locales');
const readLocalesFolder = readdirSync(localesDir);
const implementedLocales = readLocalesFolder.map(innerDir => {
if (lstatSync(resolve(localesDir, innerDir)).isDirectory()) {
return innerDir;
}
return;
});
const i18nFileSplitContent = readFileSync(I18N_FILE_PATH, 'utf-8').split('\n');
if (process.env['CEB_DEV_LOCALE']) {
i18nFileSplitContent[1] = `import localeJSON from '../locales/${process.env['CEB_DEV_LOCALE']}/messages.json' with { type: 'json' };`;
} else {
if (implementedLocales.includes(locale)) {
i18nFileSplitContent[1] = `import localeJSON from '../locales/${locale}/messages.json' with { type: 'json' };`;
} else if (implementedLocales.includes(localeWithoutRegion)) {
i18nFileSplitContent[1] = `import localeJSON from '../locales/${localeWithoutRegion}/messages.json' with { type: 'json' };`;
} else {
i18nFileSplitContent[1] = `import localeJSON from '../locales/en/messages.json' with { type: 'json' };`;
}
}
// Join lines back together
const updatedI18nFile = i18nFileSplitContent.join('\n');
writeFileSync(I18N_FILE_PATH, updatedI18nFile, 'utf-8');
};

View File

@@ -0,0 +1,12 @@
import enMessage from '../locales/en/messages.json' with { type: 'json' };
import type { SUPPORTED_LANGUAGES } from './consts.js';
export type SupportedLanguagesKeysType = keyof typeof SUPPORTED_LANGUAGES;
export type SupportedLanguagesWithoutRegionKeysType = Exclude<SupportedLanguagesKeysType, `${string}_${string}`>;
export type I18nValueType = {
message: string;
placeholders?: Record<string, { content?: string; example?: string }>;
};
export type MessageKeyType = keyof typeof enMessage;
export type LocalesJSONType = typeof enMessage;

View File

@@ -0,0 +1,45 @@
{
"extensionDescription": {
"description": "Extension description",
"message": "Chrome extension boilerplate developed with Vite, React and Typescript"
},
"extensionName": {
"description": "Extension name",
"message": "Chrome extension boilerplate"
},
"toggleTheme": {
"message": "Toggle theme"
},
"injectButton": {
"message": "Click to inject Content Scripts"
},
"greeting": {
"description": "Greeting message",
"message": "Hello, My name is $NAME$",
"placeholders": {
"name": {
"content": "$1",
"example": "John Doe"
}
}
},
"hello": {
"description": "Placeholder example",
"message": "Hello $1"
},
"displayErrorInfo": {
"message": "Error occur"
},
"displayErrorDescription": {
"message": "Sorry, something went wrong while loading the page."
},
"displayErrorDetailsInfo": {
"message": "Error details:"
},
"displayErrorUnknownErrorInfo": {
"message": "Unknown error"
},
"displayErrorReset": {
"message": "Run Again"
}
}

View File

@@ -0,0 +1,45 @@
{
"extensionDescription": {
"description": "Extension description",
"message": "React, Typescript, Vite를 사용한 크롬 익스텐션 보일러플레이트입니다."
},
"extensionName": {
"description": "Extension name",
"message": "크롬 익스텐션 보일러플레이트"
},
"toggleTheme": {
"message": "테마 변경"
},
"injectButton": {
"message": "콘텐츠 스크립트를 삽입하려면 클릭하세요."
},
"greeting": {
"description": "인사 메시지",
"message": "안녕하세요, 제 이름은 $NAME$입니다.",
"placeholders": {
"name": {
"content": "$1",
"example": "홍길동"
}
}
},
"hello": {
"description": "Placeholder 예시",
"message": "안녕하세요, $1님."
},
"displayErrorInfo": {
"message": "오류 발생"
},
"displayErrorDescription": {
"message": "죄송합니다. 페이지를 로드하는 중 문제가 발생했습니다."
},
"displayErrorDetailsInfo": {
"message": "오류 세부 정보:"
},
"displayErrorUnknownErrorInfo": {
"message": "알 수 없는 오류"
},
"displayErrorReset": {
"message": "다시 실행합니다."
}
}

View File

@@ -0,0 +1,30 @@
{
"name": "@extension/i18n",
"version": "0.5.0",
"description": "chrome extension - internationalization",
"type": "module",
"private": true,
"sideEffects": false,
"files": [
"dist/**"
],
"main": "dist/index.mjs",
"types": "index.mts",
"scripts": {
"clean:bundle": "rimraf dist",
"clean:node_modules": "pnpx rimraf node_modules",
"clean:turbo": "rimraf .turbo",
"clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo",
"ready": "tsc -b prepare-build.tsconfig.json && node --env-file=../../.env dist/lib/prepare-build.js && tsc -b",
"lint": "eslint .",
"lint:fix": "pnpm lint --fix",
"format": "prettier . --write --ignore-path ../../.prettierignore",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@extension/env": "workspace:*"
},
"devDependencies": {
"@extension/tsconfig": "workspace:*"
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["lib/prepare-build.ts"]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@extension/tsconfig/module",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist"
},
"include": ["index.mts", "lib"]
}

View File

@@ -0,0 +1,64 @@
# Module manager
Module manager is a tool to manage modules in a project. It can be used to delete or recover some pages.
## Usage
### On Root
```bash
$ pnpm module-manager
```
OR
```bash
pnpm module-manager -d popup
```
### On Module
```bash
$ pnpm start
```
OR
```bash
pnpm start -d popup
```
> [!NOTE]
> For complete info about CLI support run:
> `pnpm module-manager --help`
> [!IMPORTANT]
> If you want to remove all tests, and something else with one command, you need to set `tests` as first argument like:
> `pnpm module-manager -d tests popup ...`
### Choose a tool
```
? Choose a tool (Use arrow keys)
Delete Feature
Recover Feature
```
## How it works
### Delete Feature
When you select an unused module, Module Manager compresses the contents of that folder, takes a snapshot of it, and removes it.
It also automatically removes anything that needs to be cleared from the manifest.
### Recover Feature
When you select a module that has been deleted, The Module Manager will recover that module from the snapshot and add it back to the manifest.
> [!IMPORTANT]
> SAVE ALL FILES FROM /ARCHIVE, WITHOUT THESE FILES' RECOVERING PROCESS WON'T WORK
> [!IMPORTANT]
> IF YOU DECIDE TO REMOVE ONE OF CONTENT SCRIPTs AFTER EDIT, E.G YOU REMOVE OR ADD ANY MATCHES,
> REMEMBER TO EDIT ALSO [MODULE_CONFIG](./lib/const.ts) TO BE ABLE TO RECOVER IT EASILY IN THE FUTURE.

View File

@@ -0,0 +1,3 @@
import { startManager } from './lib/index.js';
startManager();

View File

@@ -0,0 +1,51 @@
import { CLI_OPTIONS, DEFAULT_CHOICES_VALUES, HELP_EXAMPLES } from '../const.js';
import { checkCliArgsIsValid } from '../helpers/utils.js';
import { excludeValuesFromBaseArray } from '@extension/shared';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import type { ICLIOptions, ModuleNameType } from '../types.js';
import type { WritableDeep } from '@extension/shared';
export const processCLIArgs = (): ICLIOptions | null => {
const argv = yargs(hideBin(process.argv))
.option('delete', CLI_OPTIONS[0])
.option('recover', CLI_OPTIONS[1])
.option('delete-exclude', CLI_OPTIONS[2])
.option('recover-exclude', CLI_OPTIONS[3])
.check(argv => checkCliArgsIsValid(argv))
.strict()
.example(HELP_EXAMPLES as WritableDeep<typeof HELP_EXAMPLES>)
.help()
.showHelpOnFail(true)
.parseSync();
if (argv.delete) {
return {
action: 'delete',
targets: argv.delete as ModuleNameType[],
};
}
if (argv.recover) {
return {
action: 'recover',
targets: argv.recover as ModuleNameType[],
};
}
if (argv.deleteExclude) {
return {
action: 'delete',
targets: excludeValuesFromBaseArray(DEFAULT_CHOICES_VALUES, argv.deleteExclude),
};
}
if (argv.recoverExclude) {
return {
action: 'recover',
targets: excludeValuesFromBaseArray(DEFAULT_CHOICES_VALUES, argv.recoverExclude),
};
}
return null;
};

View File

@@ -0,0 +1,2 @@
export * from './cli-args-processor.js';
export * from './run-module-manager.js';

View File

@@ -0,0 +1,49 @@
import manifest from '../../../../chrome-extension/manifest.js';
import { MANAGER_ACTION_PROMPT_CONFIG } from '../const.js';
import { promptSelection } from '../helpers/utils.js';
import { deleteFeature, recoverFeature } from '../processing/index.js';
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { CliActionType, ModuleNameType } from '../types.js';
import type { ManifestType } from '@extension/shared';
const manifestPath = resolve(import.meta.dirname, '..', '..', '..', '..', 'chrome-extension', 'manifest.ts');
const manifestObject = JSON.parse(JSON.stringify(manifest)) as ManifestType;
const manifestString = readFileSync(manifestPath, 'utf-8');
export const runModuleManager = async (moduleName?: ModuleNameType, action?: CliActionType, isLastLap = true) => {
if (!action) {
action = (await promptSelection(MANAGER_ACTION_PROMPT_CONFIG)) as Awaited<CliActionType>;
}
switch (action) {
case 'delete':
await deleteFeature(manifestObject, moduleName);
break;
case 'recover':
await recoverFeature(manifestObject, moduleName);
}
const updatedManifest = manifestString
.replace(
/const manifest = {[\s\S]*?} satisfies/,
'const manifest = ' + JSON.stringify(manifestObject, null, 2) + ' satisfies',
)
.replace(/ {2}"version": "[\s\S]*?",/, ' version: packageJson.version,');
writeFileSync(manifestPath, updatedManifest);
if (isLastLap) {
execSync('pnpm i', {
stdio: 'inherit',
cwd: resolve('..', '..'),
});
execSync('pnpm -F chrome-extension lint:fix', {
stdio: 'inherit',
cwd: resolve('..', '..'),
});
}
};

Some files were not shown because too many files have changed in this diff Show More