Compare commits
195 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d97164c20 | |||
| 6d526e7043 | |||
| 946072fc5a | |||
| 6b8dfdf02d | |||
| ba85fdf470 | |||
| 295f8601e3 | |||
| a38ccd8624 | |||
| b2dcec2f8a | |||
| e8355029d9 | |||
| 2b374b98d2 | |||
| d14d76a0c8 | |||
| d0f1c7065c | |||
| 3edb66d76d | |||
| bba8cdace1 | |||
| 22f82a67ee | |||
| b32c60ca73 | |||
| c93620cba5 | |||
| dd05ed4d4c | |||
| 21636defda | |||
| bb022646e3 | |||
| 37ad2c7ceb | |||
| b992631e00 | |||
| 35a5cc6717 | |||
| 310b56fe5d | |||
| 148234382e | |||
| c1a97b77b0 | |||
| 557ccb4854 | |||
| 83e7e8aab9 | |||
| 7bf490c59d | |||
| 9670babdbb | |||
| 42dfc2d5cf | |||
| 1527f0e275 | |||
| 66bd957ee0 | |||
| 72e3c5baa2 | |||
| 9318346ab3 | |||
| 1d6aceab0a | |||
| c1b5a9d270 | |||
| f0b2a12724 | |||
| c45677ed48 | |||
| 7a3f075e21 | |||
| 1e12ce2a0d | |||
| eca30296a7 | |||
| 41fa320815 | |||
| 873c24fa50 | |||
| d92db5ba8f | |||
| 1a6fe33f97 | |||
| 2db4ea77a9 | |||
| ba5349ccaf | |||
| addb689de9 | |||
| 2af8d28b82 | |||
| ddbc66813e | |||
| 0c8fc013a8 | |||
| 26e30aaae9 | |||
| 6557dfe430 | |||
| 9cee9eefb4 | |||
| f9cd7aaf05 | |||
| 9049804413 | |||
| d027a496f5 | |||
| dd7abddb7c | |||
| 98d2792e02 | |||
| 39b76d3ec0 | |||
| bc29bfd434 | |||
| 5e60f37ec5 | |||
| 6f901b32eb | |||
| 927aca612e | |||
| 2dcdae91eb | |||
| eb758bdeb8 | |||
| 5acf51af9d | |||
| f9718a1b71 | |||
| cf7f23d4c3 | |||
| 62ecb6f41e | |||
| fc45c37f22 | |||
| 68874e607e | |||
| 35865adcb2 | |||
| 3b63a51475 | |||
| d32e59e38f | |||
| dab8efa140 | |||
| c298d8552b | |||
| 87267347f3 | |||
| 326e84b667 | |||
| 4b005b450e | |||
| 3bf40fc4ed | |||
| 9ace395e11 | |||
| 64f42064ae | |||
| a525d786f1 | |||
| 948ee810da | |||
| 8687b2980e | |||
| 6f0814bc97 | |||
| 148c0da51f | |||
| ba09f3e0f1 | |||
| 54e24652da | |||
| d6516142d2 | |||
| 460667e3c0 | |||
| 6ed61d0766 | |||
| 971daf2784 | |||
| 89d8ef9a50 | |||
| a329e24818 | |||
| 249cf63da8 | |||
| 70a496108a | |||
| 214889e052 | |||
| b6ab7a9567 | |||
| 1ceb9e2e80 | |||
| 15ea81514f | |||
| 21d4e6dede | |||
| 5343430c0f | |||
| 8c834835c4 | |||
| 9a2cc18f64 | |||
| 8683bd4b77 | |||
| dca52864bb | |||
| 0c5643f648 | |||
| b7cdf1597c | |||
| 2770974261 | |||
| ab6a34afb6 | |||
| b0eb7da9b4 | |||
| 4f649fc872 | |||
| 1af64aa02f | |||
| c35acff259 | |||
| 995d4f8080 | |||
| b7e2aa3d8f | |||
| e347f4529f | |||
| 3d9683418d | |||
| bdce363eed | |||
| e7775f291a | |||
| 59f6a28c65 | |||
| f7534464bc | |||
| 01445fb1da | |||
| 1360eb7c01 | |||
| c99a281169 | |||
| 76e63e24c0 | |||
| f5ba1173da | |||
| 12079e6ffc | |||
| ff6740d429 | |||
| e6657cb533 | |||
| 2ad099bf7e | |||
| 6421ab34a4 | |||
| 8919c4015a | |||
| 712f2ed4ac | |||
| d1b9e5ded6 | |||
| 86182044d1 | |||
| e1f181356c | |||
| 5f241cf407 | |||
| b4a2cf4cb2 | |||
| aea137a3a1 | |||
| ad0e8e4144 | |||
| 7fd5752ef1 | |||
| 1a8b7ff635 | |||
| fe3ca0fcbd | |||
| f49ee9f005 | |||
| 961440c0ac | |||
| 9b562b0ca8 | |||
| 7b2563b2dc | |||
| 63f507ecf5 | |||
| 0a88955394 | |||
| 291d25b675 | |||
| 6f902cc598 | |||
| fde488ac82 | |||
| 0873c24ad8 | |||
| 7c02497148 | |||
| 25441a3b97 | |||
| f1cf866fe1 | |||
| d92a126489 | |||
| bcb93afe4e | |||
| 21cf3642cb | |||
| 2ef28ec0c4 | |||
| 59094c9af3 | |||
| ba41691296 | |||
| 8786aa91b3 | |||
| 0623d10653 | |||
| 85174d4994 | |||
| 4e2dd5d618 | |||
| 1a39a0d153 | |||
| f922648dd7 | |||
| e6142e3608 | |||
| ed73451fd0 | |||
| 8a25a2f60f | |||
| 397e9a8b45 | |||
| 7cef7680de | |||
| 0c57375758 | |||
| 2c6af84165 | |||
| 58e1371701 | |||
| 0d8e7463b8 | |||
| 77eeacaf0f | |||
| e80c134150 | |||
| 03c8ab4d6f | |||
| df13b9705c | |||
| 762ba0080d | |||
| 317b1b6dcc | |||
| 93175a6477 | |||
| 60d6707b11 | |||
| 97cbda3dab | |||
| 1611c1dba6 | |||
| b59252284a | |||
| 742b99657a | |||
| e2997a43e0 | |||
| 21dead60fd |
@@ -12,14 +12,22 @@ A clear and concise description of what the bug is.
|
||||
|
||||
### To Reproduce
|
||||
|
||||
Upload a ZIP or share a link to a repo representing the minimal reproduction. **_If you don't upload a minimal reproduction, you bug report will be closed._**
|
||||
Share minimal reproduction. Examples of a minimal reproduction:
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
- PR with a failing test case
|
||||
- ZIP file containing a minimal WXT project (be sure to include your lockfile!!)
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
> **_⚠️ If you don't upload a minimal reproduction, your issue will be closed until a reproduction is added._**
|
||||
>
|
||||
> Why? https://antfu.me/posts/why-reproductions-are-required
|
||||
|
||||
Steps to reproduce the bug using the reproduction:
|
||||
|
||||
1. Install dependencies: `pnpm i`
|
||||
2. Start dev mode: `pnpm dev`
|
||||
3. Click this...
|
||||
4. Do that...
|
||||
5. Etc...
|
||||
|
||||
### Expected behavior
|
||||
|
||||
|
||||
@@ -9,3 +9,7 @@ updates:
|
||||
directory: '/' # Location of package manifests
|
||||
schedule:
|
||||
interval: 'monthly'
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'monthly'
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Publish Docs
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Docker Image Tag
|
||||
required: true
|
||||
default: latest
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: https://${{ secrets.DOCKER_REGISTRY_HOSTNAME }}
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
- run: pnpm docs:build
|
||||
- run: docker build docs/.vitepress -t ${{ secrets.DOCKER_REGISTRY_HOSTNAME }}/wxt/docs:${{ github.event.inputs.tag || 'latest' }}
|
||||
- run: docker push ${{ secrets.DOCKER_REGISTRY_HOSTNAME }}/wxt/docs:${{ github.event.inputs.tag || 'latest' }}
|
||||
- run: curl -X POST -i ${{ secrets.UPDATE_DOCS_WEBHOOK }}
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
needs:
|
||||
- validate
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
@@ -10,24 +10,30 @@ jobs:
|
||||
formatting:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
- run: pnpm format:check
|
||||
lint:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
- run: pnpm lint
|
||||
type-check:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
- run: pnpm compile
|
||||
validate-demo:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
- run: pnpm build:all
|
||||
working-directory: demo
|
||||
- run: pnpm tsc --noEmit
|
||||
working-directory: demo
|
||||
- run: pnpm build:all
|
||||
working-directory: demo
|
||||
- run: pnpm wxt zip
|
||||
working-directory: demo
|
||||
- run: pnpm vitest run
|
||||
@@ -35,17 +41,17 @@ jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
- name: pnpm test:coverage
|
||||
run: pnpm test:coverage --reporter=default --reporter=hanging-process
|
||||
- uses: codecov/codecov-action@v3
|
||||
- uses: codecov/codecov-action@v4
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
windows-tests:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
- run: pnpm test run
|
||||
template:
|
||||
@@ -59,18 +65,18 @@ jobs:
|
||||
- vanilla
|
||||
- vue
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
- run: pnpm pack
|
||||
- run: npm i
|
||||
working-directory: templates/${{ matrix.template }}
|
||||
- run: npm i -D ../../wxt-*.tgz
|
||||
working-directory: templates/${{ matrix.template }}
|
||||
- run: npm run compile
|
||||
- run: pnpm compile
|
||||
if: matrix.template != 'svelte'
|
||||
working-directory: templates/${{ matrix.template }}
|
||||
- run: npm run check
|
||||
- run: pnpm check
|
||||
if: matrix.template == 'svelte'
|
||||
working-directory: templates/${{ matrix.template }}
|
||||
- run: npm run build
|
||||
- run: pnpm build
|
||||
working-directory: templates/${{ matrix.template }}
|
||||
|
||||
+666
@@ -1,5 +1,671 @@
|
||||
# Changelog
|
||||
|
||||
## v0.16.6
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.16.5...v0.16.6)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Add option to customize the analysis artifacts output ([#431](https://github.com/wxt-dev/wxt/pull/431))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Use `insertBefore` on mounting content script UI ([ba85fdf](https://github.com/wxt-dev/wxt/commit/ba85fdf))
|
||||
|
||||
### 💅 Refactors
|
||||
|
||||
- Use `Element.prepend` on mounting UI ([295f860](https://github.com/wxt-dev/wxt/commit/295f860))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Fix `createShadowRootUi` unmount calls ([946072f](https://github.com/wxt-dev/wxt/commit/946072f))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Enable skipped test since it works now ([6b8dfdf](https://github.com/wxt-dev/wxt/commit/6b8dfdf))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Lionelhorn
|
||||
- Okou
|
||||
|
||||
## v0.16.5
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.16.4...v0.16.5)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Support node 20 when running `wxt submit` ([e835502](https://github.com/wxt-dev/wxt/commit/e835502))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Remove "coming soon" from automated publishing feature ([2b374b9](https://github.com/wxt-dev/wxt/commit/2b374b9))
|
||||
|
||||
## v0.16.4
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.16.3...v0.16.4)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Automatically convert MV3 `web_accessible_resources` to MV2 ([#423](https://github.com/wxt-dev/wxt/pull/423))
|
||||
- Add option to customize the analysis output filename ([#426](https://github.com/wxt-dev/wxt/pull/426))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Don't use immer for `transformManifest` ([#424](https://github.com/wxt-dev/wxt/pull/424))
|
||||
- Exclude analysis files from the build summary ([#425](https://github.com/wxt-dev/wxt/pull/425))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Fix fake path in test data generator ([d0f1c70](https://github.com/wxt-dev/wxt/commit/d0f1c70))
|
||||
|
||||
## v0.16.3
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.16.2...v0.16.3)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Hooks ([#419](https://github.com/wxt-dev/wxt/pull/419))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- **init:** Use `ungh` to prevent rate limits when loading templates ([37ad2c7](https://github.com/wxt-dev/wxt/commit/37ad2c7))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Fix typo of intuitive ([#415](https://github.com/wxt-dev/wxt/pull/415))
|
||||
- Fix typo of opinionated ([#416](https://github.com/wxt-dev/wxt/pull/416))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Add dependabot for github actions ([#404](https://github.com/wxt-dev/wxt/pull/404))
|
||||
- **deps-dev:** Bump happy-dom from 12.10.3 to 13.3.8 ([#411](https://github.com/wxt-dev/wxt/pull/411))
|
||||
- **deps-dev:** Bump typescript from 5.3.2 to 5.3.3 ([#409](https://github.com/wxt-dev/wxt/pull/409))
|
||||
- Register global `wxt` instance ([#418](https://github.com/wxt-dev/wxt/pull/418))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Chen Hua ([@hcljsq](https://github.com/hcljsq))
|
||||
- Florian Metz ([@Timeraa](http://github.com/Timeraa))
|
||||
|
||||
## v0.16.2
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.16.1...v0.16.2)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Don't crash background service worker when using `import.meta.url` ([#402](https://github.com/wxt-dev/wxt/pull/402))
|
||||
|
||||
## v0.16.1
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.16.0...v0.16.1)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Don't require config to run `wxt submit init` ([9318346](https://github.com/wxt-dev/wxt/commit/9318346))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Add premid extension to homepage ([#399](https://github.com/wxt-dev/wxt/pull/399))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- **templates:** Upgrade to wxt `^0.16.0` ([f0b2a12](https://github.com/wxt-dev/wxt/commit/f0b2a12))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Florian Metz ([@Timeraa](http://github.com/Timeraa))
|
||||
|
||||
## v0.16.0
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.15.4...v0.16.0)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- ⚠️ ESM background support ([#398](https://github.com/wxt-dev/wxt/pull/398))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Document how to opt into ESM ([1e12ce2](https://github.com/wxt-dev/wxt/commit/1e12ce2))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- **deps-dev:** Bump lint-staged from 15.2.0 to 15.2.1 ([#395](https://github.com/wxt-dev/wxt/pull/395))
|
||||
- **deps-dev:** Bump p-map from 7.0.0 to 7.0.1 ([#396](https://github.com/wxt-dev/wxt/pull/396))
|
||||
- **deps-dev:** Bump @vitest/coverage-v8 from 1.0.1 to 1.2.2 ([#397](https://github.com/wxt-dev/wxt/pull/397))
|
||||
|
||||
#### ⚠️ Breaking Changes
|
||||
|
||||
In [#398](https://github.com/wxt-dev/wxt/pull/398), HTML pages' JS entrypoints in the output directory have been moved. Unless you're doing some kind of post-build work referencing files, you don't have to make any changes.
|
||||
|
||||
- Before:
|
||||
```
|
||||
.output/
|
||||
<target>/
|
||||
chunks/
|
||||
some-shared-chunk-<hash>.js
|
||||
popup-<hash>.js
|
||||
popup.html
|
||||
```
|
||||
- After:
|
||||
```
|
||||
.output/
|
||||
<target>/
|
||||
chunks/
|
||||
some-shared-chunk-<hash>.js
|
||||
popup.html
|
||||
popup.js
|
||||
```
|
||||
|
||||
This effects all HTML files, not just the Popup. The hash has been removed, and it's been moved to the root of the build target folder, not inside the `chunks/` directory. Moving files like this has not historically increased review times or triggered in-depth reviews when submitting updates to the stores.
|
||||
|
||||
## v0.15.4
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.15.3...v0.15.4)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- **submit:** Load `.env.submit` automatically when running `wxt submit` and `wxt submit init` ([#391](https://github.com/wxt-dev/wxt/pull/391))
|
||||
|
||||
## v0.15.3
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.15.2...v0.15.3)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- **dev:** Reload `<name>/index.html` entrypoints properly on save ([#390](https://github.com/wxt-dev/wxt/pull/390))
|
||||
|
||||
## v0.15.2
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.15.1...v0.15.2)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Add `submit` command ([#370](https://github.com/wxt-dev/wxt/pull/370))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- **dev:** Resolve `script` and `link` aliases ([#387](https://github.com/wxt-dev/wxt/pull/387))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Nenad Novaković ([@dvlden](https://github.com/dvlden))
|
||||
|
||||
## v0.15.1
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.15.0...v0.15.1)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Allow passing custom preferences to chrome, enabling dev mode on `chrome://extensions` and allowing content script sourcemaps automatically ([#384](https://github.com/wxt-dev/wxt/pull/384))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- **security:** Upgrade to vite@5.0.12 to resolve CVE-2024-23331 ([39b76d3](https://github.com/wxt-dev/wxt/commit/39b76d3))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Fixed doc errors on the guide/extension-api page ([#383](https://github.com/wxt-dev/wxt/pull/383))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Fix vite version conflicts in demo extension ([98d2792](https://github.com/wxt-dev/wxt/commit/98d2792))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- 0x7a7a ([@0x7a7a](https://github.com/0x7a7a))
|
||||
|
||||
## v0.15.0
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.7...v0.15.0)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- **zip:** ⚠️ Add `includeSources` and rename `ignoredSources` to `excludeSources` ([#378](https://github.com/wxt-dev/wxt/pull/378))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Generate missing sourcemap in `wxt:unimport` plugin ([#381](https://github.com/wxt-dev/wxt/pull/381))
|
||||
- ⚠️ Move browser constants to `import.meta.env` ([#380](https://github.com/wxt-dev/wxt/pull/380))
|
||||
- Enable inline sourcemaps by default during development ([#382](https://github.com/wxt-dev/wxt/pull/382))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Fix typo ([f9718a1](https://github.com/wxt-dev/wxt/commit/f9718a1))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Update contributor docs ([eb758bd](https://github.com/wxt-dev/wxt/commit/eb758bd))
|
||||
|
||||
#### ⚠️ Breaking Changes
|
||||
|
||||
Renamed `zip.ignoredSources` to `zip.excludeSources` in [#378](https://github.com/wxt-dev/wxt/pull/378)
|
||||
|
||||
Renamed undocumented constants for detecting the build config at runtime in [#380](https://github.com/wxt-dev/wxt/pull/380). Now documented here: https://wxt.dev/guide/multiple-browsers.html#runtime
|
||||
|
||||
- `__BROWSER__` → `import.meta.env.BROWSER`
|
||||
- `__COMMAND__` → `import.meta.env.COMMAND`
|
||||
- `__MANIFEST_VERSION__` → `import.meta.env.MANIFEST_VERSION`
|
||||
- `__IS_CHROME__` → `import.meta.env.CHROME`
|
||||
- `__IS_FIREFOX__` → `import.meta.env.FIREFOX`
|
||||
- `__IS_SAFARI__` → `import.meta.env.SAFARI`
|
||||
- `__IS_EDGE__` → `import.meta.env.EDGE`
|
||||
- `__IS_OPERA__` → `import.meta.env.OPERA`
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Nenad Novaković ([@dvlden](https://github.com/dvlden))
|
||||
|
||||
## v0.14.7
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.6...v0.14.7)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Improve error messages when importing and building entrypoints ([3b63a51](https://github.com/wxt-dev/wxt/commit/3b63a51))
|
||||
- **storage:** Throw better error message when importing outside a extension environment ([35865ad](https://github.com/wxt-dev/wxt/commit/35865ad))
|
||||
- Upgrade `web-ext-run` ([62ecb6f](https://github.com/wxt-dev/wxt/commit/62ecb6f))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Add `matches` to content script examples ([dab8efa](https://github.com/wxt-dev/wxt/commit/dab8efa))
|
||||
- Fix incorrect sample code ([#372](https://github.com/wxt-dev/wxt/pull/372))
|
||||
- Document defined constants for the build target ([68874e6](https://github.com/wxt-dev/wxt/commit/68874e6))
|
||||
- Add missing `await` to `createShadowRootUi` examples ([fc45c37](https://github.com/wxt-dev/wxt/commit/fc45c37))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- 東奈比 ([@dongnaebi](http://github.com/dongnaebi))
|
||||
|
||||
## v0.14.6
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.5...v0.14.6)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Restart dev mode when saving config ([#365](https://github.com/wxt-dev/wxt/pull/365))
|
||||
- Add basic validation for entrypoint options ([#368](https://github.com/wxt-dev/wxt/pull/368))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Add subdependency bin directory so `wxt build --analyze` works with PNPM ([#363](https://github.com/wxt-dev/wxt/pull/363))
|
||||
- Sort build output files naturally ([#364](https://github.com/wxt-dev/wxt/pull/364))
|
||||
|
||||
### 🤖 CI
|
||||
|
||||
- Check for type errors in demo before building ([4b005b4](https://github.com/wxt-dev/wxt/commit/4b005b4))
|
||||
|
||||
## v0.14.5
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.4...v0.14.5)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Add `dev.reloadCommand` config ([#362](https://github.com/wxt-dev/wxt/pull/362))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Disable reload dev command when 4 commands are already registered ([#361](https://github.com/wxt-dev/wxt/pull/361))
|
||||
|
||||
## v0.14.4
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.3...v0.14.4)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Allow requiring built-in node modules from ESM CLI ([#356](https://github.com/wxt-dev/wxt/pull/356))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Add unit tests for passing flags via the CLI ([#354](https://github.com/wxt-dev/wxt/pull/354))
|
||||
|
||||
## v0.14.3
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.2...v0.14.3)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Make `getArrayFromFlags` result can be undefined ([#352](https://github.com/wxt-dev/wxt/pull/352))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Yuns ([@yunsii](http://github.com/yunsii))
|
||||
|
||||
## v0.14.2
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.1...v0.14.2)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Add `filterEntrypoints` option to speed up development ([#344](https://github.com/wxt-dev/wxt/pull/344))
|
||||
|
||||
### 🔥 Performance
|
||||
|
||||
- Only call `findEntrypoint` once per build ([#342](https://github.com/wxt-dev/wxt/pull/342))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Improve error message and document use of imported variables outside an entrypoint's `main` function ([#346](https://github.com/wxt-dev/wxt/pull/346))
|
||||
- Allow `browser.runtime.getURL` to include hashes and query params for HTML paths ([#350](https://github.com/wxt-dev/wxt/pull/350))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Fix typos and outdated ui function usage ([#347](https://github.com/wxt-dev/wxt/pull/347))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Update templates to `^0.14.0` ([70a4961](https://github.com/wxt-dev/wxt/commit/70a4961))
|
||||
- Fix typo in function name ([a329e24](https://github.com/wxt-dev/wxt/commit/a329e24))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Yuns ([@yunsii](http://github.com/yunsii))
|
||||
- Armin
|
||||
|
||||
## v0.14.1
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.0...v0.14.1)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Use `Alt+R`/`Opt+R` to reload extension during development ([b6ab7a9](https://github.com/wxt-dev/wxt/commit/b6ab7a9))
|
||||
|
||||
## v0.14.0
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.5...v0.14.0)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- ⚠️ Refactor content script UI functions and add helper for "integrated" UIs ([#333](https://github.com/wxt-dev/wxt/pull/333))
|
||||
|
||||
#### ⚠️ Breaking Changes
|
||||
|
||||
`createContentScriptUi` and `createContentScriptIframe`, and some of their options, have been renamed:
|
||||
|
||||
- `createContentScriptUi({ ... })` → `createShadowRootUi({ ... })`
|
||||
- `createContentScriptIframe({ ... })` → `createIframeUi({ ... })`
|
||||
- `type: "inline" | "overlay" | "modal"` has been changed to `position: "inline" | "overlay" | "modal"`
|
||||
- `onRemove` is now called ***before*** the UI is removed from the DOM, previously it was called after the UI was removed
|
||||
- `mount` option has been renamed to `onMount`, to better match the related option, `onRemove`.
|
||||
|
||||
## v0.13.5
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.4...v0.13.5)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Strip path from `web_accessible_resources[0].matches` ([#332](https://github.com/wxt-dev/wxt/pull/332))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Add section about customizing other browser options during development ([8683bd4](https://github.com/wxt-dev/wxt/commit/8683bd4))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Update bug report template ([9a2cc18](https://github.com/wxt-dev/wxt/commit/9a2cc18))
|
||||
|
||||
## v0.13.4
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.3...v0.13.4)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Disable minification during development ([b7cdf15](https://github.com/wxt-dev/wxt/commit/b7cdf15))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Use `const` instead of `let` ([2770974](https://github.com/wxt-dev/wxt/commit/2770974))
|
||||
|
||||
## v0.13.3
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.2...v0.13.3)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- **DX:** Add `ctrl+E`/`cmd+E` shortcut to reload extension during development ([#322](https://github.com/wxt-dev/wxt/pull/322))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- **deps-dev:** Bump tsx from 4.6.2 to 4.7.0 ([#320](https://github.com/wxt-dev/wxt/pull/320))
|
||||
- **deps-dev:** Bump prettier from 3.1.0 to 3.1.1 ([#318](https://github.com/wxt-dev/wxt/pull/318))
|
||||
- **deps-dev:** Bump vitepress from 1.0.0-rc.31 to 1.0.0-rc.34 ([#316](https://github.com/wxt-dev/wxt/pull/316))
|
||||
- Refactor manifest generation E2E tests to unit tests ([#323](https://github.com/wxt-dev/wxt/pull/323))
|
||||
|
||||
## v0.13.2
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.1...v0.13.2)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Add `isolateEvents` option to `createContentScripUi` ([#313](https://github.com/wxt-dev/wxt/pull/313))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Remove duplicate `entrypoints/` path ([76e63e2](https://github.com/wxt-dev/wxt/commit/76e63e2))
|
||||
- Update unlisted pages/scripts description ([c99a281](https://github.com/wxt-dev/wxt/commit/c99a281))
|
||||
- Update content script entrypoint docs ([1360eb7](https://github.com/wxt-dev/wxt/commit/1360eb7))
|
||||
- Add example for setting up custom panels/panes in devtools ([#308](https://github.com/wxt-dev/wxt/pull/308))
|
||||
- Use example tags to automate relevant example lists ([#311](https://github.com/wxt-dev/wxt/pull/311))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Update templates to `^0.13.0` ([#309](https://github.com/wxt-dev/wxt/pull/309))
|
||||
- Upgrade template dependencies ([#310](https://github.com/wxt-dev/wxt/pull/310))
|
||||
- Re-enable coverage ([#312](https://github.com/wxt-dev/wxt/pull/312))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- 冯不游
|
||||
|
||||
## v0.13.1
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.0...v0.13.1)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- **storage:** Support multiple `:` characters in storage keys ([#303](https://github.com/wxt-dev/wxt/pull/303))
|
||||
- Ship `vite/client` types internally for proper resolution using PNPM ([#304](https://github.com/wxt-dev/wxt/pull/304))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Reorder guide ([6421ab3](https://github.com/wxt-dev/wxt/commit/6421ab3))
|
||||
- General fixes and improvements ([2ad099b](https://github.com/wxt-dev/wxt/commit/2ad099b))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Update `scripts/build.ts` show current build step in progress, not completed count ([#306](https://github.com/wxt-dev/wxt/pull/306))
|
||||
|
||||
## v0.13.0
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.5...v0.13.0)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- ⚠️ New `wxt/storage` APIs ([#300](https://github.com/wxt-dev/wxt/pull/300))
|
||||
|
||||
#### ⚠️ Breaking Changes
|
||||
|
||||
- `wxt/storage` no longer relies on [`unstorage`](https://www.npmjs.com/package/unstorage). Some `unstorage` APIs, like `prefixStorage`, have been removed, while others, like `snapshot`, are methods on the new `storage` object. Most of the standard usage remains the same. See https://wxt.dev/guide/storage and https://wxt.dev/api/wxt/storage/ for more details ([#300](https://github.com/wxt-dev/wxt/pull/300))
|
||||
|
||||
## v0.12.5
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.4...v0.12.5)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Correct import in dev-only, noop background ([#298](https://github.com/wxt-dev/wxt/pull/298))
|
||||
|
||||
## v0.12.4
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.3...v0.12.4)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Disable Vite CJS warnings ([#296](https://github.com/wxt-dev/wxt/pull/296))
|
||||
|
||||
## v0.12.3
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.2...v0.12.3)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Correctly mock `webextension-polyfill` for Vitest ([#294](https://github.com/wxt-dev/wxt/pull/294))
|
||||
|
||||
## v0.12.2
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.1...v0.12.2)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Support PNPM without hoisting dependencies ([#291](https://github.com/wxt-dev/wxt/pull/291))
|
||||
|
||||
## v0.12.1
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.0...v0.12.1)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Upgrade `@webext-core/match-patterns` to `1.0.3` ([#289](https://github.com/wxt-dev/wxt/pull/289))
|
||||
- Fix `package.json` lint errors ([#290](https://github.com/wxt-dev/wxt/pull/290))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Upgrade templates to `wxt@^0.12.0` ([#285](https://github.com/wxt-dev/wxt/pull/285))
|
||||
|
||||
## v0.12.0
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.11.2...v0.12.0)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- ⚠️ Add support for "main world" content scripts ([#284](https://github.com/wxt-dev/wxt/pull/284))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Only use type imports for Vite ([#278](https://github.com/wxt-dev/wxt/pull/278))
|
||||
- Throw error when no entrypoints are found ([#283](https://github.com/wxt-dev/wxt/pull/283))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Improve content script UI guide ([#272](https://github.com/wxt-dev/wxt/pull/272))
|
||||
- Fix dead links ([291d25b](https://github.com/wxt-dev/wxt/commit/291d25b))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Convert WXT CLI to an ESM binary ([#279](https://github.com/wxt-dev/wxt/pull/279))
|
||||
|
||||
#### ⚠️ Breaking Changes
|
||||
|
||||
`defineContentScript` and `defineBackground` are now exported from `wxt/sandbox` instead of `wxt/client`. ([#284](https://github.com/wxt-dev/wxt/pull/284))
|
||||
|
||||
- If you use auto-imports, no changes are required.
|
||||
- If you have disabled auto-imports, you'll need to manually update your import statements:
|
||||
```diff
|
||||
- import { defineBackground, defineContentScript } from 'wxt/client';
|
||||
+ import { defineBackground, defineContentScript } from 'wxt/sandbox';
|
||||
```
|
||||
|
||||
## v0.11.2
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.11.1...v0.11.2)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Discover `.js`, `.jsx`, and `.tsx` unlisted scripts correctly ([#274](https://github.com/wxt-dev/wxt/pull/274))
|
||||
- Improve duplicate entrypoint name detection and catch the error before loading their config ([#276](https://github.com/wxt-dev/wxt/pull/276))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Improve content script UI docs ([#268](https://github.com/wxt-dev/wxt/pull/268))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Update sSolid template to vite 5 ([#265](https://github.com/wxt-dev/wxt/pull/265))
|
||||
- Add missing navigation item ([bcb93af](https://github.com/wxt-dev/wxt/commit/bcb93af))
|
||||
|
||||
## v0.11.1
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.11.0...v0.11.1)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Add util for detecting URL changes in content scripts ([#264](https://github.com/wxt-dev/wxt/pull/264))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Upgrade templates to `wxt@^0.11.0` ([#263](https://github.com/wxt-dev/wxt/pull/263))
|
||||
|
||||
## v0.11.0
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.10.4...v0.11.0)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- ⚠️ Vite 5 support ([#261](https://github.com/wxt-dev/wxt/pull/261))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Adds tl;dv to homepage ([#260](https://github.com/wxt-dev/wxt/pull/260))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Speed up CI using `pnpm` instead of `npm` ([#259](https://github.com/wxt-dev/wxt/pull/259))
|
||||
- Abstract vite from WXT's core logic ([#242](https://github.com/wxt-dev/wxt/pull/242))
|
||||
|
||||
#### ⚠️ Breaking Changes
|
||||
|
||||
- You will need to update any other Vite plugins to a version that supports Vite 5 ([#261](https://github.com/wxt-dev/wxt/pull/261))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Ítalo Brasil ([@italodeverdade](http://github.com/italodeverdade))
|
||||
|
||||
## v0.10.4
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.10.3...v0.10.4)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Add config to customize `outDir` ([#258](https://github.com/wxt-dev/wxt/pull/258))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Add Doozy to homepage ([#249](https://github.com/wxt-dev/wxt/pull/249))
|
||||
- Update sidepanel availability ([#250](https://github.com/wxt-dev/wxt/pull/250))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- **deps-dev:** Bump prettier from 3.0.3 to 3.1.0 ([#254](https://github.com/wxt-dev/wxt/pull/254))
|
||||
- **deps-dev:** Bump @types/lodash.merge from 4.6.8 to 4.6.9 ([#255](https://github.com/wxt-dev/wxt/pull/255))
|
||||
- **deps-dev:** Bump tsx from 3.14.0 to 4.6.1 ([#252](https://github.com/wxt-dev/wxt/pull/252))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- 冯不游
|
||||
|
||||
## v0.10.3
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.10.2...v0.10.3)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- **auto-imports:** Don't add imports to `node_module` dependencies ([#247](https://github.com/wxt-dev/wxt/pull/247))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Fix typo ([317b1b6](https://github.com/wxt-dev/wxt/commit/317b1b6))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Trigger docs upgrade via webhook ([742b996](https://github.com/wxt-dev/wxt/commit/742b996))
|
||||
- Use `normalize-path` instead of `vite.normalizePath` ([#244](https://github.com/wxt-dev/wxt/pull/244))
|
||||
- Use `defu` for merging some config objects ([#243](https://github.com/wxt-dev/wxt/pull/243))
|
||||
|
||||
### 🤖 CI
|
||||
|
||||
- Publish docs on push to main ([1611c1d](https://github.com/wxt-dev/wxt/commit/1611c1d))
|
||||
- Only print response headers from docs webhook ([97cbda3](https://github.com/wxt-dev/wxt/commit/97cbda3))
|
||||
|
||||
## v0.10.2
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.10.1...v0.10.2)
|
||||
|
||||
+10
-2
@@ -57,9 +57,17 @@ pnpm test
|
||||
pnpm docs:dev
|
||||
```
|
||||
|
||||
## Updating Docs
|
||||
|
||||
Documentation is written with VitePress, and is located in the `docs/` directory.
|
||||
|
||||
The API reference is generated from JSDoc comments in the source code. If there's a typo or change you want to make in there, you'll need to update the source code instead of a file in the `docs/` directory.
|
||||
|
||||
## Testing
|
||||
|
||||
WXT has unit and E2E tests. When making a change or adding a feature, make sure to update the tests or add new ones.
|
||||
WXT has unit and E2E tests. When making a change or adding a feature, make sure to update the tests or add new ones, if they exist.
|
||||
|
||||
> If they don't exist, feel free to create them, but that's a lot for a one-time contributor. A maintainer might add them to your PR though.
|
||||
|
||||
To run tests for a specific file, add the filename at the end of the test command:
|
||||
|
||||
@@ -86,7 +94,7 @@ Note that templates are hardcoded to a specific version of `wxt` from NPM, they
|
||||
|
||||
```diff
|
||||
"devDependencies": {
|
||||
"typescript": "^5.1.6",
|
||||
"typescript": "^5.3.2",
|
||||
"vite-plugin-solid": "^2.7.0",
|
||||
- "wxt": "^0.8.0"
|
||||
+ "wxt": "../.."
|
||||
|
||||
@@ -9,9 +9,8 @@
|
||||
<a href="https://www.npmjs.com/package/wxt" target="_blank"><img alt="npm" src="https://img.shields.io/npm/dm/wxt?labelColor=black&color=%234fa048"></a>
|
||||
<span> </span>
|
||||
<a href="https://github.com/wxt-dev/wxt/blob/main/LICENSE" target="_blank"><img alt="NPM" src="https://img.shields.io/npm/l/wxt?labelColor=black&color=%234fa048"></a>
|
||||
<!-- Hide code coverage while it's broken -->
|
||||
<!-- <span> </span>
|
||||
<a href="https://codecov.io/github/wxt-dev/wxt" target="_blank"><img alt="Codecov" src="https://img.shields.io/codecov/c/github/wxt-dev/wxt?labelColor=black&color=%234fa048"></a> -->
|
||||
<span> </span>
|
||||
<a href="https://codecov.io/github/wxt-dev/wxt" target="_blank"><img alt="Codecov" src="https://img.shields.io/codecov/c/github/wxt-dev/wxt?labelColor=black&color=%234fa048"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -56,14 +55,11 @@ Or see the [installation guide](https://wxt.dev/guide/installation.html) to get
|
||||
- 📂 File based entrypoints
|
||||
- 🚔 TypeScript
|
||||
- 🦾 Auto-imports
|
||||
- ⬇️ Download and bundle remote URL imports
|
||||
- 🤖 Automated publishing
|
||||
- 🎨 Frontend framework agnostic: works with Vue, React, Svelte, etc
|
||||
- 🖍️ Quickly bootstrap a new project
|
||||
- 📏 Bundle analysis
|
||||
|
||||
### Coming Soon
|
||||
|
||||
- 🤖 Automated publishing
|
||||
- ⬇️ Download and bundle remote URL imports
|
||||
|
||||
## Contributors
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
require('../dist/cli.cjs');
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
import '../dist/cli.js';
|
||||
+1
-3
@@ -19,13 +19,11 @@
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"vitest": "^0.34.6",
|
||||
"webextension-polyfill": "^0.10.0"
|
||||
"vitest": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.34",
|
||||
"@types/react-dom": "^18.2.14",
|
||||
"@types/webextension-polyfill": "^0.10.5",
|
||||
"sass": "^1.69.5",
|
||||
"wxt": "workspace:*"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import background from '../background';
|
||||
|
||||
browser.i18n.getMessage = () => 'fake-message';
|
||||
@@ -7,6 +7,10 @@ const logMock = vi.fn();
|
||||
console.log = logMock;
|
||||
|
||||
describe('Background Entrypoint', () => {
|
||||
beforeEach(() => {
|
||||
fakeBrowser.reset();
|
||||
});
|
||||
|
||||
it("should log the extenion's runtime ID", () => {
|
||||
const id = 'some-id';
|
||||
fakeBrowser.runtime.id = id;
|
||||
@@ -15,4 +19,11 @@ describe('Background Entrypoint', () => {
|
||||
|
||||
expect(logMock).toBeCalledWith(id);
|
||||
});
|
||||
|
||||
it('should set the start time in storage', async () => {
|
||||
background.main();
|
||||
await new Promise((res) => setTimeout(res));
|
||||
|
||||
expect(await storage.getItem('session:startTime')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,42 @@
|
||||
import messages from 'public/_locales/en/messages.json';
|
||||
|
||||
export default defineBackground(() => {
|
||||
console.log(browser.runtime.id);
|
||||
logId();
|
||||
console.log({
|
||||
browser: __BROWSER__,
|
||||
chrome: __IS_CHROME__,
|
||||
firefox: __IS_FIREFOX__,
|
||||
manifestVersion: __MANIFEST_VERSION__,
|
||||
messages,
|
||||
});
|
||||
export default defineBackground({
|
||||
// type: 'module',
|
||||
|
||||
// @ts-expect-error: should only accept entrypoints or public assets
|
||||
browser.runtime.getURL('/');
|
||||
browser.runtime.getURL('/background.js');
|
||||
browser.runtime.getURL('/icon/128.png');
|
||||
main() {
|
||||
console.log(browser.runtime.id);
|
||||
logId();
|
||||
console.log({
|
||||
url: import.meta.url,
|
||||
browser: import.meta.env.BROWSER,
|
||||
chrome: import.meta.env.CHROME,
|
||||
firefox: import.meta.env.FIREFOX,
|
||||
manifestVersion: import.meta.env.MANIFEST_VERSION,
|
||||
messages,
|
||||
});
|
||||
|
||||
// @ts-expect-error: should only accept known message names
|
||||
browser.i18n.getMessage('test');
|
||||
browser.i18n.getMessage('prompt_for_name');
|
||||
browser.i18n.getMessage('hello', 'Aaron');
|
||||
browser.i18n.getMessage('bye', ['Aaron']);
|
||||
browser.i18n.getMessage('@@extension_id');
|
||||
// @ts-expect-error: should only accept entrypoints or public assets
|
||||
browser.runtime.getURL('/');
|
||||
browser.runtime.getURL('/background.js');
|
||||
browser.runtime.getURL('/icon/128.png');
|
||||
browser.runtime.getURL('/example.html#hash');
|
||||
browser.runtime.getURL('/example.html?query=param');
|
||||
// @ts-expect-error: should only allow hashes/query params on HTML files
|
||||
browser.runtime.getURL('/icon-128.png?query=param');
|
||||
|
||||
console.log('WXT MODE:', {
|
||||
MODE: import.meta.env.MODE,
|
||||
DEV: import.meta.env.DEV,
|
||||
PROD: import.meta.env.PROD,
|
||||
});
|
||||
// @ts-expect-error: should only accept known message names
|
||||
browser.i18n.getMessage('test');
|
||||
browser.i18n.getMessage('prompt_for_name');
|
||||
browser.i18n.getMessage('hello', 'Aaron');
|
||||
browser.i18n.getMessage('bye', ['Aaron']);
|
||||
browser.i18n.getMessage('@@extension_id');
|
||||
|
||||
storage.setItem('session:startTime', Date.now());
|
||||
console.log('WXT MODE:', {
|
||||
MODE: import.meta.env.MODE,
|
||||
DEV: import.meta.env.DEV,
|
||||
PROD: import.meta.env.PROD,
|
||||
});
|
||||
|
||||
storage.setItem('session:startTime', Date.now());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,9 +2,9 @@ export default defineContentScript({
|
||||
matches: ['*://*.google.com/*'],
|
||||
|
||||
main(ctx) {
|
||||
const ui = createContentScriptIframe(ctx, {
|
||||
const ui = createIframeUi(ctx, {
|
||||
page: '/iframe-src.html',
|
||||
type: 'overlay',
|
||||
position: 'overlay',
|
||||
anchor: 'form[action="/search"]',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export default defineContentScript({
|
||||
// Site that uses HTML5 history
|
||||
matches: ['*://*.crunchyroll.com/*'],
|
||||
|
||||
main(ctx) {
|
||||
ctx.addEventListener(window, 'wxt:locationchange', ({ newUrl, oldUrl }) => {
|
||||
console.log('Location changed:', newUrl.href, oldUrl.href);
|
||||
});
|
||||
console.log('Watching for location change...');
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export default defineContentScript({
|
||||
matches: ['*://*/*'],
|
||||
world: 'MAIN',
|
||||
|
||||
main() {
|
||||
console.log(`Hello from ${location.hostname}!`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sidebar</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Example</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,12 +6,12 @@ export default defineContentScript({
|
||||
cssInjectionMode: 'ui',
|
||||
|
||||
async main(ctx) {
|
||||
const ui = await createContentScriptUi(ctx, {
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'demo-ui',
|
||||
type: 'inline',
|
||||
position: 'inline',
|
||||
append: 'before',
|
||||
anchor: 'form[role=search]',
|
||||
mount: (container) => {
|
||||
onMount: (container) => {
|
||||
const app = document.createElement('div');
|
||||
app.textContent = 'Custom content script UI';
|
||||
container.append(app);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
FROM lipanski/docker-static-website:latest
|
||||
COPY dist .
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
tag?: string;
|
||||
}>();
|
||||
|
||||
const examples = ref();
|
||||
onMounted(async () => {
|
||||
const res = await fetch(
|
||||
'https://raw.githubusercontent.com/wxt-dev/wxt-examples/main/examples.json',
|
||||
);
|
||||
examples.value = await res.json();
|
||||
});
|
||||
|
||||
const filteredExamples = computed(() => {
|
||||
if (props.tag == null) return examples.value;
|
||||
|
||||
return examples.value.filter((example) => {
|
||||
return example.tags?.includes(props.tag);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul>
|
||||
<li v-if="examples == null">Loading...</li>
|
||||
<template v-else>
|
||||
<li v-for="example of filteredExamples">
|
||||
<a :href="example.url" target="_blank">{{ example.name }}</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -5,11 +5,19 @@ import useListExtensionDetails, {
|
||||
} from '../composables/useListExtensionDetails';
|
||||
|
||||
// Add extension IDs here. Order doesn't matter, will be sorted by weekly active users
|
||||
// During the transition from chrome.google.com/webstore to
|
||||
// chromewebstore.google.com, queue.wxt.dev might return null for your
|
||||
// extension. If it does, use "<slug>/<id>" instead of just the ID. The slug
|
||||
// can be retrieved from the URL of the item on chromewebstore.google.com
|
||||
const chromeExtensionIds = [
|
||||
'ocfdgncpifmegplaglcnglhioflaimkd', // GitHub: Better Line Counts
|
||||
'mgmdkjcljneegjfajchedjpdhbadklcf', // Anime Skip Player
|
||||
'bfbnagnphiehemkdgmmficmjfddgfhpl', // UltraWideo
|
||||
'elfaihghhjjoknimpccccmkioofjjfkf', // StayFree - Website Blocker & Web Analytics
|
||||
'okifoaikfmpfcamplcfjkpdnhfodpkil', // Doozy: Ai Made Easy
|
||||
'lknmjhcajhfbbglglccadlfdjbaiifig', // tl;dv - Record, Transcribe & ChatGPT for Google Meet
|
||||
'youtube中文配音/oglffgiaiekgeicdgkdlnlkhliajdlja', // Youtube中文配音
|
||||
'agjnjboanicjcpenljmaaigopkgdnihi', // PreMiD
|
||||
];
|
||||
|
||||
const { data } = useListExtensionDetails(chromeExtensionIds);
|
||||
@@ -22,6 +30,7 @@ const sortedExtensions = computed(() => {
|
||||
// Sort based on the user count weighted by the rating
|
||||
sortKey: ((item.rating ?? 5) / 5) * item.weeklyActiveUsers,
|
||||
}))
|
||||
.filter((item) => !!item)
|
||||
.sort((l, r) => r.sortKey - l.sortKey);
|
||||
});
|
||||
|
||||
|
||||
@@ -84,17 +84,19 @@ export default defineConfig({
|
||||
{ text: 'Installation', link: '/guide/installation.md' },
|
||||
{ text: 'Configuration', link: '/guide/configuration.md' },
|
||||
{ text: 'Entrypoints', link: '/guide/entrypoints.md' },
|
||||
{ text: 'Assets', link: '/guide/assets.md' },
|
||||
{ text: 'Multiple Browsers', link: '/guide/multiple-browsers.md' },
|
||||
{ text: 'Publishing', link: '/guide/publishing.md' },
|
||||
{ text: 'Auto-imports', link: '/guide/auto-imports.md' },
|
||||
{ text: 'Manifest.json', link: '/guide/manifest.md' },
|
||||
{ text: 'Extension APIs', link: '/guide/extension-apis.md' },
|
||||
{ text: 'Storage', link: '/guide/storage.md' },
|
||||
{ text: 'Assets', link: '/guide/assets.md' },
|
||||
{ text: 'Content Script UI', link: '/guide/content-script-ui.md' },
|
||||
{ text: 'Multiple Browsers', link: '/guide/multiple-browsers.md' },
|
||||
{ text: 'ES Modules', link: '/guide/esm.md' },
|
||||
{ text: 'Auto-imports', link: '/guide/auto-imports.md' },
|
||||
{ text: 'Vite', link: '/guide/vite.md' },
|
||||
{ text: 'Remote Code', link: '/guide/remote-code.md' },
|
||||
{ text: 'Publishing', link: '/guide/publishing.md' },
|
||||
{ text: 'Development', link: '/guide/development.md' },
|
||||
{ text: 'Testing', link: '/guide/testing.md' },
|
||||
{ text: 'Vite', link: '/guide/vite.md' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ import DefaultTheme from 'vitepress/theme';
|
||||
import Icon from '../components/Icon.vue';
|
||||
import EntrypointPatterns from '../components/EntrypointPatterns.vue';
|
||||
import UsingWxtSection from '../components/UsingWxtSection.vue';
|
||||
import ExampleList from '../components/ExampleList.vue';
|
||||
import './custom.css';
|
||||
|
||||
export default {
|
||||
@@ -10,5 +11,6 @@ export default {
|
||||
ctx.app.component('Icon', Icon);
|
||||
ctx.app.component('EntrypointPatterns', EntrypointPatterns);
|
||||
ctx.app.component('UsingWxtSection', UsingWxtSection);
|
||||
ctx.app.component('ExampleList', ExampleList);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ When creating content script entrypoints, they are automatically included in the
|
||||
```ts
|
||||
export default defineContentScript({
|
||||
// Set manifest options
|
||||
matches: ['*://google.com/*', '*://duckduckgo.com/*'],
|
||||
matches: string[],
|
||||
excludeMatches: undefined | [],
|
||||
includeGlobs: undefined | [],
|
||||
excludeGlobs: undefined | [],
|
||||
@@ -90,7 +90,7 @@ To include CSS with your content script, import the CSS file at the top of your
|
||||
|
||||
```
|
||||
|
||||
<srcDir>
|
||||
<srcDir>/
|
||||
└─ entrypoints/
|
||||
└─ overlay.content/
|
||||
├─ index.ts
|
||||
@@ -138,198 +138,4 @@ export default defineContentScript({
|
||||
});
|
||||
```
|
||||
|
||||
## UI
|
||||
|
||||
WXT provides a utility function, `createContentScriptUi` to simplify mounting a UI from a content script. Internally, it uses the `ShadowRoot` API to isolate your CSS from the webpages.
|
||||
|
||||
`createContentScriptUi` requires a `ContentScriptContext` so that when the context is invalidated, the UI is automatically removed from the webpage.
|
||||
|
||||
:::details When to use `createContentScriptUi`
|
||||
You should only use `createContentScriptUi` if you want your UI's styles isolated from the webpages. If you want to create a more "integrated" UI that uses the page's styles, you can just use the regular JS API's to append your UI to the page.
|
||||
|
||||
```ts
|
||||
const ui = document.createElement('div');
|
||||
const anchor = document.querySelector('#anchor-selector');
|
||||
anchor.append(ui);
|
||||
```
|
||||
|
||||
You can try out [`createContentScriptIframe`](#createcontentscriptiframe) as an alternative solution
|
||||
|
||||
:::
|
||||
|
||||
### Usage
|
||||
|
||||
To use `createContentScriptUi`, follow these steps:
|
||||
|
||||
1. Import your CSS file at the top of your content script
|
||||
2. Set `cssInjectionMode: "ui"` inside `defineContentScript`
|
||||
3. Call `createContentScriptUi`
|
||||
4. Call `mount` to add the UI to the webpage
|
||||
|
||||
Here's a basic example:
|
||||
|
||||
```ts
|
||||
// entrypoints/ui.content/index.ts
|
||||
import './style.css';
|
||||
|
||||
export default defineContentScript({
|
||||
// ...
|
||||
cssInjectionMode: 'ui',
|
||||
|
||||
async main(ctx) {
|
||||
const ui = await createContentScriptUi(ctx, {
|
||||
name: 'example-ui',
|
||||
type: 'inline',
|
||||
anchor: '#some-element',
|
||||
append: 'after',
|
||||
mount(container) {
|
||||
// Mount UI inside `container`...
|
||||
},
|
||||
});
|
||||
|
||||
// You must call `mount` to add the UI to the page.
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
If you're using a frontend framework, you'll also need to include an `onRemoved` callback:
|
||||
|
||||
:::code-group
|
||||
|
||||
```ts [Vue]
|
||||
import { createApp } from 'vue';
|
||||
|
||||
createContentScriptUi(ctx, {
|
||||
// ...
|
||||
mount(container) {
|
||||
// Create a new app and mount it inside the container
|
||||
const app = createApp(...);
|
||||
app.mount(container);
|
||||
return app;
|
||||
},
|
||||
onRemove(app) {
|
||||
// When the UI is removed from the DOM, call unmount to stop the app
|
||||
app.unmount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts [React]
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
createContentScriptUi(ctx, {
|
||||
// ...
|
||||
mount(container) {
|
||||
// Create a root using the container and render your app
|
||||
const root = ReactDOM.createRoot(container);
|
||||
root.render(...);
|
||||
return root;
|
||||
},
|
||||
onRemove(root) {
|
||||
// When the UI is removed from the DOM, call unmount to stop the app
|
||||
root.unmount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts [Svelte]
|
||||
import App from './App.svelte';
|
||||
|
||||
createContentScriptUi(ctx, {
|
||||
// ...
|
||||
mount(container) {
|
||||
// Mount your app component inside the container
|
||||
return new App({
|
||||
target: container,
|
||||
});
|
||||
},
|
||||
onRemove(app) {
|
||||
// When the UI is removed from the DOM, call $destroy to stop the app
|
||||
app.$destroy();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts [Solid]
|
||||
import { render } from 'solid-js/web';
|
||||
|
||||
createContentScriptUi(ctx, {
|
||||
// ...
|
||||
mount(container) {
|
||||
// Render your app component into the container
|
||||
return render(() => ..., container)
|
||||
},
|
||||
onRemove(unmount) {
|
||||
// When the UI is removed from the DOM, call unmount to stop the app
|
||||
unmount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
### `anchor`
|
||||
|
||||
The anchor dictates where the UI will be mounted.
|
||||
|
||||
### `append`
|
||||
|
||||
Customize where the UI get's appended to the DOM, relative to the `anchor` element.
|
||||
|
||||
### `type`
|
||||
|
||||
There are 3 types of UI's you can mount.
|
||||
|
||||
- `inline`: Shows up inline based on the `anchor` and `append` options
|
||||
- `overlay`: Shows up inline, but styled to be 0px by 0px, with overflow visible. This causes the UI to overlay on top of the webpage's content
|
||||
- `modal`: A fullscreen overlay that covers the entire screen, regardless of where it's anchored.
|
||||
|
||||
> TODO: Add visualization of the different UI types.
|
||||
|
||||
### Overlay `alignment`
|
||||
|
||||
Because the overlay UI type results in a 0px by 0px container being added to the webpage, the `alignment` option allows you to configure which corner of your UI is aligned with the 0x0 element.
|
||||
|
||||
> TODO: Add visualization of the different alignments.
|
||||
|
||||
## IFrame
|
||||
|
||||
WXT provides a utility function, `createContentScriptIframe` to simplify mounting a UI from a content script. It creates an iframe to an unlisted HTML page. Unlike `createContentScriptUi`, this API support HMR.
|
||||
|
||||
`createContentScriptIframe` requires a `ContentScriptContext` so that when the context is invalidated, the UI is automatically removed from the webpage.
|
||||
|
||||
### Usage
|
||||
|
||||
To use `createContentScriptIframe`, follow these steps:
|
||||
|
||||
1. Create an unlisted HTML page that will be loaded into your iframe
|
||||
1. Add unlisted page to the manifest's `web_accessible_resouces`
|
||||
1. Call `createContentScriptUi`
|
||||
1. Call `mount` to add the UI to the webpage
|
||||
|
||||
Here's a basic example:
|
||||
|
||||
```ts
|
||||
export default defineContentScript({
|
||||
// ...
|
||||
|
||||
async main(ctx) {
|
||||
const ui = await createContentScriptIframe(ctx, {
|
||||
page: '/your-unlisted-page.html',
|
||||
type: 'inline',
|
||||
anchor: '#some-element',
|
||||
append: 'after',
|
||||
});
|
||||
|
||||
// You must call `mount` to add the UI to the page.
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The options, other than `page`, are the same as [`createContentScriptUi`](#anchor).
|
||||
|
||||
### `page`
|
||||
|
||||
The HTML page you want to load inside the iframe. This string will be passed into `browser.runtime.getURL` to resolve the full path of to your HTML page.
|
||||
See [Content Script UI](/guide/content-script-ui) for more info on creating UIs and including CSS in content scripts.
|
||||
|
||||
@@ -13,15 +13,12 @@
|
||||
|
||||
## Definition
|
||||
|
||||
Plain old HTML file.
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Title</title>
|
||||
<!-- Set include/exclude if the page should be removed from some builds -->
|
||||
<meta name="manifest.include" content="['chrome', ...]" />
|
||||
<meta name="manifest.exclude" content="['chrome', ...]" />
|
||||
@@ -31,3 +28,13 @@ Plain old HTML file.
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Adding UI Elements
|
||||
|
||||
Chrome extensions allow you to add panels and side panes to the devtools window.
|
||||
|
||||

|
||||
|
||||
See the WXT's examples for a full walkthrough of extending the devtools window:
|
||||
|
||||
<ExampleList tag="devtools" />
|
||||
|
||||
@@ -10,10 +10,10 @@ Firefox does not support sandboxed pages.
|
||||
|
||||
<EntrypointPatterns
|
||||
:patterns="[
|
||||
['entrypoints/sandbox.html', 'sandbox.html'],
|
||||
['entrypoints/sandbox/index.html', 'sandbox.html'],
|
||||
['entrypoints/<name>.sandbox.html', '<name>.html` '],
|
||||
['entrypoints/<name>.sandbox/index.html', '<name>.html` '],
|
||||
['sandbox.html', 'sandbox.html'],
|
||||
['sandbox/index.html', 'sandbox.html'],
|
||||
['<name>.sandbox.html', '<name>.html` '],
|
||||
['<name>.sandbox/index.html', '<name>.html` '],
|
||||
]"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# Side Panel
|
||||
|
||||
[Chrome Docs](https://developer.chrome.com/docs/extensions/reference/sidePanel/)
|
||||
[Chrome Docs](https://developer.chrome.com/docs/extensions/reference/sidePanel/) • [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/user_interface/Sidebars)
|
||||
|
||||
:::tip Chromium Only
|
||||
Firefox does not support sidepanel pages.
|
||||
:::warning
|
||||
Chrome added support for sidepanels in Manifest V3, they are not available in Manfiest V2.
|
||||
:::
|
||||
|
||||
## Filenames
|
||||
|
||||
<EntrypointPatterns
|
||||
:patterns="[
|
||||
['entrypoints/sidepanel.html', 'sidepanel.html'],
|
||||
['entrypoints/sidepanel/index.html', 'sidepanel.html'],
|
||||
['entrypoints/<name>.sidepanel.html', '<name>.html` '],
|
||||
['entrypoints/<name>.sidepanel/index.html', '<name>.html` '],
|
||||
['sidepanel.html', 'sidepanel.html'],
|
||||
['sidepanel/index.html', 'sidepanel.html'],
|
||||
['<name>.sidepanel.html', '<name>.html` '],
|
||||
['<name>.sidepanel/index.html', '<name>.html` '],
|
||||
]"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Unlisted Pages
|
||||
|
||||
HTML pages that are built by Vite, but are not included in the manifest.
|
||||
HTML pages that are bundled and shipped with the extension, but are not included in the manifest.
|
||||
|
||||
### Examples
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Unlisted Scripts
|
||||
|
||||
TypeScript files that are built, but are not included in the manifest.
|
||||
TypeScript files that are bundled and shipped with the extension, but are not included in the manifest.
|
||||
|
||||
You are responsible for loading/running these scripts where needed.
|
||||
|
||||
|
||||
+1
-21
@@ -2,26 +2,6 @@
|
||||
|
||||
Simple walkthroughs to accomplish common tasks or patterns with WXT.
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
const examples = ref()
|
||||
onMounted(async () => {
|
||||
const res = await fetch("https://raw.githubusercontent.com/wxt-dev/wxt-examples/main/examples.json");
|
||||
examples.value = await res.json();
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
<li v-if="examples == null">
|
||||
Loading...
|
||||
</li>
|
||||
<template v-else>
|
||||
<li v-for="example of examples">
|
||||
<a :href="example.url" target="_blank">{{ example.name }}</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<ExampleList />
|
||||
|
||||
> Full code available at [`wxt-dev/wxt-examples`](https://github.com/wxt-dev/wxt-examples)
|
||||
|
||||
@@ -11,13 +11,15 @@ To setup your test environment for auto-imports, see [Testing](/guide/testing).
|
||||
Some WXT APIs can be used without importing them:
|
||||
|
||||
- [`browser`](/api/wxt/browser/variables/browser) from `wxt/browser`, a small wrapper around `webextension-polyfill`
|
||||
- [`defineContentScript`](/api/wxt/client/functions/defineContentScript) from `wxt/client`
|
||||
- [`defineBackground`](/api/wxt/client/functions/defineBackground) from `wxt/client`
|
||||
- [`createContentScriptUi`](/api/wxt/client/functions/createContentScriptUi) from `wxt/client`
|
||||
- [`defineContentScript`](/api/wxt/sandbox/functions/defineContentScript) from `wxt/sandbox`
|
||||
- [`defineBackground`](/api/wxt/sandbox/functions/defineBackground) from `wxt/sandbox`
|
||||
- [`defineUnlistedScript`](/api/wxt/sandbox/functions/defineUnlistedScript) from `wxt/sandbox`
|
||||
- [`createIntegratedUi`](/api/wxt/client/functions/createIntegratedUi) from `wxt/client`
|
||||
- [`createShadowRootUi`](/api/wxt/client/functions/createShadowRootUi) from `wxt/client`
|
||||
- [`createIframeUi`](/api/wxt/client/functions/createIframeUi) from `wxt/client`
|
||||
- [`fakeBrowser`](/api/wxt/testing/variables/fakeBrowser) from `wxt/testing`
|
||||
|
||||
And more. All `wxt/*` APIs can be used without imports.
|
||||
And more!
|
||||
|
||||
## Project Auto-imports
|
||||
|
||||
|
||||
+10
-12
@@ -51,12 +51,6 @@ export default defineConfig({
|
||||
});
|
||||
```
|
||||
|
||||
## Vite Config
|
||||
|
||||
[Vite](https://vitejs.dev/) is the bundler used to build each entrypoint of your extension. Vite can be configured via the `vite` option.
|
||||
|
||||
See [Vite's documentation](https://vitejs.dev/config/) for configuring the bundler.
|
||||
|
||||
## Frontend Frameworks
|
||||
|
||||
Adding a framework like Vue, React, or Svelte is easy!
|
||||
@@ -70,9 +64,9 @@ import { defineConfig } from 'wxt';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
vite: () => ({
|
||||
plugins: [vue()],
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
@@ -81,9 +75,9 @@ import { defineConfig } from 'wxt';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
vite: () => ({
|
||||
plugins: [react()],
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
@@ -92,10 +86,14 @@ import { defineConfig } from 'wxt';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
vite: () => ({
|
||||
plugins: [svelte()],
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
:::info
|
||||
Internally, WXT runs `vite build` multiple times during a single build. It can't share the same instance of some plugins between each build, so a function is used to return completely new instances of each plugin for each build.
|
||||
:::
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
# Content Script UI
|
||||
|
||||
There are three ways to mount a UI inside a content script:
|
||||
|
||||
[[toc]]
|
||||
|
||||
Each has their own set of advantages and disadvantages.
|
||||
|
||||
| Method | Isolated Styles | Isolated Events | HMR | Use page's context |
|
||||
| ----------- | :-------------: | :-----------------: | :-: | :----------------: |
|
||||
| Integrated | ❌ | ❌ | ❌ | ✅ |
|
||||
| Shadow Root | ✅ | ✅ (off by default) | ❌ | ✅ |
|
||||
| IFrame | ✅ | ✅ | ✅ | ❌ |
|
||||
|
||||
## Integrated
|
||||
|
||||
Integrated content script UIs are injected alongside the content of a page. This means that they are affected by CSS on that page.
|
||||
|
||||
:::code-group
|
||||
|
||||
```ts [Vanilla]
|
||||
// entrypoints/example-ui.content.ts
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
|
||||
main(ctx) {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#anchor',
|
||||
onMount: (container) => {
|
||||
// Append children to the container
|
||||
const app = document.createElement('p');
|
||||
app.textContent = '...';
|
||||
container.append(app);
|
||||
},
|
||||
});
|
||||
|
||||
// Call mount to add the UI to the DOM
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts [Vue]
|
||||
// entrypoints/example-ui.content/index.ts
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
|
||||
main(ctx) {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#anchor',
|
||||
onMount: (container) => {
|
||||
// Create the app and mount it to the UI container
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
return app;
|
||||
},
|
||||
onRemove: (app) => {
|
||||
// Unmount the app when the UI is removed
|
||||
app.unmount();
|
||||
},
|
||||
});
|
||||
|
||||
// Call mount to add the UI to the DOM
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```tsx [React]
|
||||
// entrypoints/example-ui.content/index.tsx
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
|
||||
main(ctx) {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#anchor',
|
||||
onMount: (container) => {
|
||||
// Create a root on the UI container and render a component
|
||||
const root = ReactDOM.createRoot(container);
|
||||
root.render(<App />);
|
||||
return root;
|
||||
},
|
||||
onRemove: (root) => {
|
||||
// Unmount the root when the UI is removed
|
||||
root.unmount();
|
||||
},
|
||||
});
|
||||
|
||||
// Call mount to add the UI to the DOM
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts [Svelte]
|
||||
// entrypoints/example-ui.content/index.ts
|
||||
import App from './App.svelte';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
|
||||
main(ctx) {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#anchor',
|
||||
onMount: (container) => {
|
||||
// Create the Svelte app inside the UI container
|
||||
const app = new App({
|
||||
target: container,
|
||||
});
|
||||
return app;
|
||||
},
|
||||
onRemove: (app) => {
|
||||
// Destroy the app when the UI is removed
|
||||
app.$destroy();
|
||||
},
|
||||
});
|
||||
|
||||
// Call mount to add the UI to the DOM
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```tsx [Solid]
|
||||
// entrypoints/example-ui.content/index.ts
|
||||
import { render } from 'solid-js/web';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
|
||||
main(ctx) {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#anchor',
|
||||
onMount: (container) => {
|
||||
// Render your app to the UI container
|
||||
const unmount = render(() => <div>...</div>, container);
|
||||
},
|
||||
onRemove: (unmount) => {
|
||||
// Unmount the app when the UI is removed
|
||||
unmount();
|
||||
},
|
||||
});
|
||||
|
||||
// Call mount to add the UI to the DOM
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
See the [API Reference](/api/wxt/client/functions/createIntegratedUi) for the complete list of options.
|
||||
|
||||
You can control how CSS is injected for an integrated content script UI with the [`cssInjectionMode`](/api/wxt/interfaces/ContentScriptBaseDefinition#cssinjectionmode) property. Usually, you'll want to leave it as `"manifest"`, the default, so the UI inherits its style from the website's CSS.
|
||||
|
||||
## Shadow Root
|
||||
|
||||
Often in web extensions, you don't want your content script's CSS affecting the page, or vise-versa. The [`ShadowRoot`](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot) API is ideal for this.
|
||||
|
||||
WXT's [`createShadowRootUi`](/api/wxt/client/functions/createShadowRootUi) abstracts all the `ShadowRoot` setup away, making it easy to create UIs with isolated CSS. It also supports an optional `isolateEvents` parameter to further isolate user interactions.
|
||||
|
||||
To use `createShadowRootUi`, follow these steps:
|
||||
|
||||
1. Import your CSS file at the top of your content script
|
||||
2. Set [`cssInjectionMode: "ui"`](/api/wxt/interfaces/ContentScriptBaseDefinition#cssinjectionmode) inside `defineContentScript`
|
||||
3. Define your UI with `createShadowRootUi()`
|
||||
4. Mount the UI so it is visible to users
|
||||
|
||||
:::code-group
|
||||
|
||||
```ts [Vanilla]
|
||||
// 1. Import the style
|
||||
import './style.css';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
// 2. Set cssInjectionMode
|
||||
cssInjectionMode: 'ui',
|
||||
|
||||
async main(ctx) {
|
||||
// 3. Define your UI
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'example-ui',
|
||||
anchor: '#anchor',
|
||||
position: 'inline',
|
||||
onMount(container) {
|
||||
// Define how your UI will be mounted inside the container
|
||||
const app = document.createElement('p');
|
||||
app.textContent = 'Hello world!';
|
||||
container.append(app);
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Mount the UI
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts [Vue]
|
||||
// 1. Import the style
|
||||
import './style.css';
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
// 2. Set cssInjectionMode
|
||||
cssInjectionMode: 'ui',
|
||||
|
||||
async main(ctx) {
|
||||
// 3. Define your UI
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'example-ui',
|
||||
anchor: '#anchor',
|
||||
position: 'inline',
|
||||
onMount: (container) => {
|
||||
// Define how your UI will be mounted inside the container
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
return app;
|
||||
},
|
||||
onRemove: (app) => {
|
||||
// Unmount the app when the UI is removed
|
||||
app?.unmount();
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Mount the UI
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```tsx [React]
|
||||
// 1. Import the style
|
||||
import './style.css';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
// 2. Set cssInjectionMode
|
||||
cssInjectionMode: 'ui',
|
||||
|
||||
async main(ctx) {
|
||||
// 3. Define your UI
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'example-ui',
|
||||
position: 'inline',
|
||||
anchor: '#anchor',
|
||||
onMount: (container) => {
|
||||
// Create a root on the UI container and render a component
|
||||
const root = ReactDOM.createRoot(container);
|
||||
root.render(<App />);
|
||||
return root;
|
||||
},
|
||||
onRemove: (root) => {
|
||||
// Unmount the root when the UI is removed
|
||||
root?.unmount();
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Mount the UI
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts [Svelte]
|
||||
// 1. Import the style
|
||||
import './style.css';
|
||||
import App from './App.svelte';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
// 2. Set cssInjectionMode
|
||||
cssInjectionMode: 'ui',
|
||||
|
||||
async main(ctx) {
|
||||
// 3. Define your UI
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'example-ui',
|
||||
position: 'inline',
|
||||
anchor: '#anchor',
|
||||
onMount: (container) => {
|
||||
// Create the Svelte app inside the UI container
|
||||
const app = new App({
|
||||
target: container,
|
||||
});
|
||||
return app;
|
||||
},
|
||||
onRemove: (app) => {
|
||||
// Destroy the app when the UI is removed
|
||||
app?.$destroy();
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Mount the UI
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```tsx [Solid]
|
||||
// 1. Import the style
|
||||
import './style.css';
|
||||
import { render } from 'solid-js/web';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
// 2. Set cssInjectionMode
|
||||
cssInjectionMode: 'ui',
|
||||
|
||||
async main(ctx) {
|
||||
// 3. Define your UI
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'example-ui',
|
||||
position: 'inline',
|
||||
anchor: '#anchor',
|
||||
onMount: (container) => {
|
||||
// Render your app to the UI container
|
||||
const unmount = render(() => <div>...</div>, container);
|
||||
},
|
||||
onRemove: (unmount) => {
|
||||
// Unmount the app when the UI is removed
|
||||
unmount?.();
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Mount the UI
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
See the [API Reference](/api/wxt/client/functions/createShadowRootUi) for the complete list of options.
|
||||
|
||||
:::info TailwindCSS
|
||||
`createShadowRootUi` supports TailwindCSS out of the box! When importing the styles, just import the main CSS file containing the `@tailwind` directives, and everything will just work :+1:.
|
||||
:::
|
||||
|
||||
## IFrame
|
||||
|
||||
If you don't need to run your UI in the same frame as the content script, you can use an IFrame to host your UI instead. Since an IFrame just hosts an HTML page, **_HMR is supported_**.
|
||||
|
||||
WXT provides a helper function, [`createIframeUi`](/api/wxt/client/functions/createIframeUi), which simplifies setting up the IFrame.
|
||||
|
||||
1. Create an HTML page that will be loaded into your IFrame
|
||||
```html
|
||||
<!-- entrypoints/example-iframe.html -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Your Content Script IFrame</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- ... -->
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
1. Add the page to the manifest's `web_accessible_resouces`
|
||||
```ts
|
||||
// wxt.config.ts
|
||||
export default defineConfig({
|
||||
manifest: {
|
||||
web_accessible_resources: [
|
||||
{
|
||||
resources: ['example-iframe.html'],
|
||||
matches: [...],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
1. Create and mount the IFrame
|
||||
|
||||
```ts
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
|
||||
async main(ctx) {
|
||||
// Define the UI
|
||||
const ui = await createIframeUi(ctx, {
|
||||
page: '/example-iframe.html',
|
||||
anchor: '#anchor',
|
||||
position: 'inline',
|
||||
onMount: (wrapper, iframe) => {
|
||||
// Add styles to the iframe like width
|
||||
iframe.width = 123;
|
||||
},
|
||||
});
|
||||
|
||||
// Show UI to user
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
See the [API Reference](/api/wxt/client/functions/createIframeUi) for the complete list of options.
|
||||
@@ -39,6 +39,8 @@ export default defineRunnerConfig({
|
||||
|
||||
:::
|
||||
|
||||
### Browser Binaries
|
||||
|
||||
`web-ext`'s browser discovery is very limitted. By default, it only guesses at where Chrome and Firefox are installed. If you've customized your install locations, you may need to tell `web-ext` where the binaries/executables are located using the [`binaries` option](/api/wxt/interfaces/ExtensionRunnerConfig#binaries). For other Chromium based browsers, like Edge or Opera, you'll need to explicitly list them in the `binaries` option as well, otherwise they will open in Chrome by default.
|
||||
|
||||
```ts
|
||||
@@ -57,3 +59,30 @@ export default defineRunnerConfig({
|
||||
:::tip
|
||||
When configuring browser binaries, it's helpful to put them in `~/web-ext.config.ts` instead of the project directory's `web-ext.config.ts` file. When placed in your home directory (`~/`), this config will be used by all WXT projects, so you only need to configure the binaries once.
|
||||
:::
|
||||
|
||||
### Other options
|
||||
|
||||
You can customize other options as well, like startup URLs, profiles, or additional command line arguments:
|
||||
|
||||
```ts
|
||||
// web-ext.config.ts
|
||||
import { defineRunnerConfig } from 'wxt';
|
||||
|
||||
export default defineRunnerConfig({
|
||||
startUrls: ['https://google.com', 'https://duckduckgo.com'],
|
||||
chromiumProfile: '/path/to/profile/to/use',
|
||||
chromiumArgs: ['--window-size=400x300'],
|
||||
});
|
||||
```
|
||||
|
||||
For a full list of options, see the [API Reference](/api/wxt/interfaces/ExtensionRunnerConfig).
|
||||
|
||||
## Reload the Extension
|
||||
|
||||
Normally, to manually reload an extension, you have to visit `chrome://extensions` and click the reload button for your extension.
|
||||
|
||||
When running `wxt` command to start the dev server, WXT adds a keyboard shortcut, `ctrl+E` for Windows/Linux and `cmd+E` for Mac, that reloads the extension when pressed, without visiting `chrome://extensions`.
|
||||
|
||||
:::info
|
||||
This shortcut is only available during development, and is not be added to your extension when running `wxt build` or `wxt-zip`.
|
||||
:::
|
||||
|
||||
@@ -86,3 +86,56 @@ export default defineContentScript({
|
||||
:::info
|
||||
For a full list of entrypoints and each of their options, see the [`/entrypoints` folder](/entrypoints/background) documentation.
|
||||
:::
|
||||
|
||||
### Side Effects
|
||||
|
||||
You cannot use imported variables outside the `main` function is JS entrypoints. This includes options, as shown below:
|
||||
|
||||
```ts
|
||||
// entrypoints/content.ts
|
||||
import { GOOGLE_MATCHES } from '~/utils/match-patterns';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: GOOGLE_MATCHES,
|
||||
main() {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```
|
||||
$ wxt build
|
||||
wxt build
|
||||
|
||||
WXT 0.14.1
|
||||
ℹ Building chrome-mv3 for production with Vite 5.0.5
|
||||
✖ Command failed after 360 ms
|
||||
|
||||
[8:55:54 AM] ERROR entrypoints/content.ts: Cannot use imported variable "GOOGLE_MATCHES" before main function. See https://wxt.dev/guide/entrypoints.html#side-effects
|
||||
```
|
||||
|
||||
This throws an error because WXT needs to import each entrypoint during the build process to extract its definition (containing the `match`, `run_at`, `include`/`exclude`, etc.) to render the `manifest.json` correctly. Before loading an entrypoint, a transformation is applied to remove all imports. This prevents imported modules (local or NPM) with side-effects from running during the build process, potentially throwing an error.
|
||||
|
||||
:::details Why?
|
||||
|
||||
When importing your entrypoint to get its definition, the file is imported in a **_node environement_**, and doesn't have access to the `window`, `chrome`, or `browser` globals a web extension ususally has access to. If WXT doesn't remove all the imports from the file, the imported modules could try and access one of these variables, throwing an error.
|
||||
|
||||
:::
|
||||
|
||||
:::warning
|
||||
See [`wxt-dev/wxt#336`](https://github.com/wxt-dev/wxt/issues/336) to track the status of this bug.
|
||||
:::
|
||||
|
||||
Usually, this error occurs when you try to extract options into a shared file or try to run code outside the `main` function. To fix the example from above, use litteral values when defining an entrypoint instead of importing them:
|
||||
|
||||
```ts
|
||||
import { GOOGLE_MATCHES } from '~/utils/match-patterns'; // [!code --]
|
||||
|
||||
export default defineContentScript({
|
||||
matches: GOOGLE_MATCHES, // [!code --]
|
||||
matches: ['*//*.google.com/*'], // [!code ++]
|
||||
main() {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# ES Modules
|
||||
|
||||
Configure entrypoints to use ESM at runtime.
|
||||
|
||||
Currently, ESM entrypoints are opt-in, so you must configure each entrypoint with that in mind.
|
||||
|
||||
## HTML Pages <Badge type="warning" text="≥0.0.1" />
|
||||
|
||||
In general, you should always make HTML pages import ESM scripts, unless you need to support old browsers.
|
||||
|
||||
To make a script ESM, add `type="module"`:
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
```html
|
||||
<script src="./main.ts"></script> <!-- [!code --] -->
|
||||
<script src="./main.ts" type="module"></script> <!-- [!code ++] -->
|
||||
```
|
||||
|
||||
## Background <Badge type="warning" text="≥0.16.0" />
|
||||
|
||||
In your background script, set `type: "module"`:
|
||||
|
||||
```ts
|
||||
export default defineBackground({
|
||||
type: 'module', // !code ++
|
||||
main() {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
:::warning
|
||||
Only MV3 support ESM background scripts/service workers. When targetting MV2, the `type` option is ignored and the background is always bundled into a single file as IIFE.
|
||||
:::
|
||||
|
||||
## Content Scripts
|
||||
|
||||
Coming soon. Follow [Content Script ESM Support #357](https://github.com/wxt-dev/wxt/issues/357) for updates.
|
||||
@@ -57,21 +57,19 @@ Here's a basic request/response example:
|
||||
// popup/main.ts
|
||||
const res = await browser.runtime.sendMessage('ping');
|
||||
|
||||
console.log('res'); // "pong"
|
||||
console.log(res); // "pong"
|
||||
```
|
||||
|
||||
```ts
|
||||
// background.ts
|
||||
export default defineBackground(() => {
|
||||
browser.runtime.onMessage.addEventListener(
|
||||
(message, sender, sendResponse) => {
|
||||
console.log(message); // "ping"
|
||||
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
console.log(message); // "ping"
|
||||
|
||||
// Wait 1 second and respond with "pong"
|
||||
setTimeout(() => sendResponse('pong'), 1000);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
// Wait 1 second and respond with "pong"
|
||||
setTimeout(() => sendResponse('pong'), 1000);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -145,5 +145,6 @@ If you're an experienced web extension developer and think the dev manifest look
|
||||
You're ready to build your web extension!
|
||||
|
||||
- Learn how to [add entrypoints](./entrypoints) like the popup, options page, or content scripts
|
||||
- Configure your entrypoints to [use ESM](./esm) at runtime
|
||||
- [Configure WXT](./configuration) by creating a `wxt.config.ts` file
|
||||
- Checkout [example projects](https://github.com/wxt-dev/wxt-examples) to see how to perfom common tasks with WXT
|
||||
|
||||
@@ -7,7 +7,7 @@ head:
|
||||
|
||||
# Introduction
|
||||
|
||||
WXT is a free and open source framework for building web extensions in an conventional, intuative, and safe way **_for all browsers_**.
|
||||
WXT is a free and open source framework for building web extensions in an conventional, intuitive, and safe way **_for all browsers_**.
|
||||
|
||||
WXT is based of [Nuxt](https://nuxt.com), and aims to provide the same great DX with TypeScript, auto-imports, and an opinionated project structure.
|
||||
|
||||
@@ -15,7 +15,7 @@ WXT is based of [Nuxt](https://nuxt.com), and aims to provide the same great DX
|
||||
|
||||
## Conventions
|
||||
|
||||
WXT is an optionated framework. This helps keep projects consistent and easy to pick up.
|
||||
WXT is an opinionated framework. This helps keep projects consistent and easy to pick up.
|
||||
|
||||
- **Generated manifest**: Based on your project's file structure
|
||||
- **Entrypoint configuration**: Configure entrypoints from the same file they're declare in
|
||||
|
||||
+10
-3
@@ -1,12 +1,13 @@
|
||||
# Manifest.json
|
||||
|
||||
The manifest.json is generated at build-time based on files in your `entrypoints` directory and your `wxt.config.ts`.
|
||||
The manifest.json is generated at build-time based on files in the `entrypoints/` directory and `wxt.config.ts`.
|
||||
|
||||
## Configuration
|
||||
|
||||
While entrypoints are generated and added to the manifest at build-time, you can customize or add to your `manifest.json` in the config file.
|
||||
|
||||
```ts
|
||||
// wxt.config.tsentrypoint of your extension
|
||||
import { defineConfig } from 'wxt';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -25,11 +26,13 @@ If not provided via the `manifest` config, the [manifest's `name`](https://devel
|
||||
|
||||
## `version` and `version_name`
|
||||
|
||||
The [manifest's `version` and `version_name`](https://developer.chrome.com/docs/extensions/mv3/manifest/version/) fields are based on your package.json's `version` property.
|
||||
The [manifest's `version` and `version_name`](https://developer.chrome.com/docs/extensions/mv3/manifest/version/) properties are based on the `version` field listed in your `package.json` or `wxt.config.ts`.
|
||||
|
||||
- `version_name` is the exact string listed in your package.json
|
||||
- `version_name` is the exact string listed in your `package.json` or `wxt.config.ts` file
|
||||
- `version` is the string cleaned up, with any invalid suffixes removed
|
||||
|
||||
If a version is not found, a warning is logged and the version defaults to `"0.0.0"`.
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
@@ -118,3 +121,7 @@ export default defineConfig({
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
See the official localization examples for more details:
|
||||
|
||||
<ExampleList tag="i18n" />
|
||||
|
||||
@@ -17,11 +17,9 @@ Separate build targets are written to their own output directories:
|
||||
|
||||
To build for a specific browser, pass the `-b --browser` flag from the CLI:
|
||||
|
||||
```
|
||||
|
||||
```sh
|
||||
wxt --browser firefox
|
||||
wxt build --browser firefox
|
||||
|
||||
```
|
||||
|
||||
By default, it will build for `chrome`. When excluding the [manifest version flags](#target-manifest-version), it will default to the commonly accepted manifest version for that browser.
|
||||
@@ -112,3 +110,24 @@ export default defineContentScript({
|
||||
:::warning
|
||||
Only `defineBackground` and `defineContentScript` support per-browser options right now.
|
||||
:::
|
||||
|
||||
## Runtime
|
||||
|
||||
To determine the browser or manifest version at runtime, you can use any of the below variables:
|
||||
|
||||
- `import.meta.env.BROWSER`: A string, the target browser, usually equal to the `--browser` flag
|
||||
- `import.meta.env.MANIFEST_VERSION`: A number, either `2` or `3`, depending on the manifest version targetted
|
||||
- `import.meta.env.CHROME`: A boolean equivalent to `import.meta.env.BROWSER === "chrome"`
|
||||
- `import.meta.env.FIREFOX`: A boolean equivalent to `import.meta.env.BROWSER === "firefox"`
|
||||
- `import.meta.env.EDGE`: A boolean equivalent to `import.meta.env.BROWSER === "edge"`
|
||||
- `import.meta.env.SAFARI`: A boolean equivalent to `import.meta.env.BROWSER === "safari"`
|
||||
- `import.meta.env.OPERA`: A boolean equivalent to `import.meta.env.BROWSER === "opera"`
|
||||
- `import.meta.env.COMMAND`: A string, `"serve"` when running `wxt` for development or `"build"` in all other cases.
|
||||
|
||||
:::info
|
||||
These variables are constants defined at build time based on the build target. They do not actually detect which browser the code is running in.
|
||||
|
||||
For example, if you build for `--browser chrome` and publish it on Edge, `import.meta.env.BROWSER` will be `"chrome"`, not `"edge"`. You have to build a separate ZIP for `--browser edge` before `import.meta.env.BROWSER` will be `"edge"`.
|
||||
|
||||
If you need to know the actual browser your code is being ran on, you should use a [user agent parser](https://www.npmjs.com/package/ua-parser-js).
|
||||
:::
|
||||
|
||||
+76
-90
@@ -1,94 +1,66 @@
|
||||
---
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# Publishing
|
||||
|
||||
WXT offers several utilities that simplify the publishing process.
|
||||
WXT will help you ZIP your extensions and submit them to the stores for review.
|
||||
|
||||
## First Time Publishing
|
||||
|
||||
If you're publishing an extension to a store for the first time, it's recommended that you manually navigate the process. Each store has unique steps and requirements that you need to familiarize yourself with.
|
||||
If you're publishing an extension to a store for the first time, you must manually navigate the process. WXT doesn't help you create listings, each store has unique steps and requirements that you need to familiarize yourself with.
|
||||
|
||||
Each store requires that a ZIP file be uploaded. You can generate these using the `wxt zip` command:
|
||||
For specific details about each store, see the stores sections below.
|
||||
|
||||
- [Chrome Web Store](#chrome-web-store)
|
||||
- [Firefox Addon Store](#firefox-addon-store)
|
||||
- [Edge Addons](#edge-addons)
|
||||
|
||||
## Automation
|
||||
|
||||
WXT provides two commands to help automate the release process:
|
||||
|
||||
- `wxt submit`: Submit new versions of your extension for review (and publish them automatically once approved)
|
||||
- `wxt submit init`: Help setup all the required secrets and options for the `wxt submit` command
|
||||
|
||||
To get started, run `wxt submit init` and follow the prompts. Once finished, you should have a `.env.submit` file! WXT will use this file to submit your updates.
|
||||
|
||||
> In CI, make sure you add all the environment variables to the submit step.
|
||||
|
||||
To release an update, build all the ZIPs you plan on releasing:
|
||||
|
||||
```sh
|
||||
wxt zip
|
||||
wxt zip -b firefox
|
||||
# etc
|
||||
```
|
||||
|
||||
Generated ZIP files are stored in the `.output` directory.
|
||||
Then run the `wxt submit` command, passing in all the ZIP files you want to release. In this case, we'll do a release for all 3 major stores: Chrome Web Store, Edge Addons, and Firefox Addons Store.
|
||||
|
||||
## Automation
|
||||
If it's your first time running the command, you'll want to test your secrets by passing the `--dry-run` flag:
|
||||
|
||||
To automate releasing updates, use the [`publish-browser-extension`](https://www.npmjs.com/package/publish-browser-extension) package.
|
||||
```sh
|
||||
wxt submit --dry-run \
|
||||
--chrome-zip .output/<your-extension>-<version>-chrome.zip \
|
||||
--firefox-zip .output/<your-extension>-<version>-firefox.zip --firefox-sources-zip .output/<your-extension>-<version>-sources.zip \
|
||||
--edge-zip .output/<your-extension>-<version>-chrome.zip
|
||||
```
|
||||
|
||||
:::info
|
||||
🚧 WXT plans to eventually incorporate the `publish-browser-extension` package into its own `wxt submit` command.
|
||||
If the dry run passes, remove the flag and do the actual release:
|
||||
|
||||
```sh
|
||||
wxt submit \
|
||||
--chrome-zip .output/<your-extension>-<version>-chrome.zip \
|
||||
--firefox-zip .output/<your-extension>-<version>-firefox.zip --firefox-sources-zip .output/<your-extension>-<version>-sources.zip \
|
||||
--edge-zip .output/<your-extension>-<version>-chrome.zip
|
||||
```
|
||||
|
||||
:::tip
|
||||
If you only need to release to a single store, only pass that store's ZIP flag.
|
||||
:::
|
||||
|
||||
1. Install the necessary dependencies:
|
||||
|
||||
```sh
|
||||
pnpm add -D publish-browser-extension env-cmd
|
||||
```
|
||||
|
||||
2. Add scripts to your `package.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"submit": "env-cmd -f .env.submit -- publish-extension",
|
||||
"submit:dry": "env-cmd -f .env.submit -- publish-extension --dry-run"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Create a `.env.submit` file and include the code below. If you're not publishing to certain stores, simply ignore their respective variables.
|
||||
|
||||
```txt
|
||||
CHROME_EXTENSION_ID=""
|
||||
CHROME_CLIENT_ID=""
|
||||
CHROME_CLIENT_SECRET=""
|
||||
CHROME_REFRESH_TOKEN=""
|
||||
|
||||
FIREFOX_EXTENSION_ID=""
|
||||
FIREFOX_JWT_ISSUER=""
|
||||
FIREFOX_JWT_SECRET=""
|
||||
|
||||
EDGE_PRODUCT_ID=""
|
||||
EDGE_CLIENT_ID=""
|
||||
EDGE_CLIENT_SECRET=""
|
||||
EDGE_ACCESS_TOKEN_URL=""
|
||||
```
|
||||
|
||||
> Each value will be filled in during the next step.
|
||||
|
||||
4. Run `npx publish-extension --help` for assistance with filling out all the values. Insert the obtained values within the double quotes.
|
||||
|
||||
5. ZIP all the targets you plan to publish, in this case Chrome and Firefox.
|
||||
|
||||
```sh
|
||||
wxt zip
|
||||
wxt zip -b firefox
|
||||
```
|
||||
|
||||
6. Test your credentials by running the `submit:dry` command:
|
||||
|
||||
```sh
|
||||
pnpm submit:dry \
|
||||
--chrome-zip .output/your-extension-X.Y.Z-chrome.zip \
|
||||
--firefox-zip .output/your-extension-X.Y.Z-firefox.zip \
|
||||
--firefox-sources-zip .output/your-extension-X.Y.Z-sources.zip \
|
||||
--edge-zip .output/your-extension-X.Y.Z-chrome.zip
|
||||
```
|
||||
|
||||
7. Upload and submit your extension for review:
|
||||
|
||||
```sh
|
||||
pnpm submit \
|
||||
--chrome-zip .output/your-extension-X.Y.Z-chrome.zip \
|
||||
--firefox-zip .output/your-extension-X.Y.Z-firefox.zip \
|
||||
--firefox-sources-zip .output/your-extension-X.Y.Z-sources.zip \
|
||||
--edge-zip .output/your-extension-X.Y.Z-chrome.zip
|
||||
```
|
||||
:::tip
|
||||
See the [Firefox Addon Store](#firefox-addon-store) section for more details about the `--firefox-sources-zip` option.
|
||||
:::
|
||||
|
||||
## GitHub Action
|
||||
|
||||
@@ -98,9 +70,11 @@ Here's an example of a GitHub Action to automate submiting new versions of your
|
||||
# TODO
|
||||
```
|
||||
|
||||
## Chrome Web Store
|
||||
## Stores
|
||||
|
||||
✅ Automated • [Developer Dashboard](https://chrome.google.com/webstore/developer/dashboard) • [Publishing Docs](https://developer.chrome.com/docs/webstore/publish/)
|
||||
### Chrome Web Store
|
||||
|
||||
> ✅ Supported • [Developer Dashboard](https://chrome.google.com/webstore/developer/dashboard) • [Publishing Docs](https://developer.chrome.com/docs/webstore/publish/)
|
||||
|
||||
To create a ZIP for Chrome:
|
||||
|
||||
@@ -108,15 +82,15 @@ To create a ZIP for Chrome:
|
||||
wxt zip
|
||||
```
|
||||
|
||||
## Firefox Addon Store
|
||||
### Firefox Addon Store
|
||||
|
||||
✅ Automated • [Developer Dashboard](https://addons.mozilla.org/developers/) • [Publishing Docs](https://extensionworkshop.com/documentation/publish/submitting-an-add-on/)
|
||||
> ✅ Supported • [Developer Dashboard](https://addons.mozilla.org/developers/) • [Publishing Docs](https://extensionworkshop.com/documentation/publish/submitting-an-add-on/)
|
||||
|
||||
Firefox requires you to upload a ZIP of your source code. This allows them to rebuild your extension and review the code in a readable way. More details can be found in [Firefox's docs](https://extensionworkshop.com/documentation/publish/source-code-submission/).
|
||||
|
||||
WXT and `publish-browser-extension` both fully support generating and automatically submitting a source code ZIP.
|
||||
WXT fully supports generating and automatically submitting a source code ZIP.
|
||||
|
||||
When you run `wxt zip -b firefox`, your sources are zipped into the `.output` directory along with your built extension. WXT is configured to exclude certain files such as config files, hidden files, and tests. However, it's important to manually check the ZIP to ensure it only contains the files necessary to rebuild your extension.
|
||||
When you run `wxt zip -b firefox`, your sources are zipped into the `.output` directory alongside the extension. WXT will automatically exclude certain files such as config files, hidden files, and tests. However, it's important to manually check the ZIP to ensure it only contains the files necessary to rebuild your extension.
|
||||
|
||||
To customize which files are zipped, add the `zip` option to your config file.
|
||||
|
||||
@@ -152,22 +126,34 @@ yarn zip:firefox
|
||||
|
||||
:::
|
||||
|
||||
Ensure that you have a `README.md` or `SOURCE_CODE_REVIEW.md` file with the above commands so that the Firefox team knows how to build your extension.
|
||||
|
||||
## Safari
|
||||
|
||||
🚧 Not automated at this time
|
||||
Make sure the build output is the exact same when running `wxt build -b firefox` in your main project and inside the zipped sources.
|
||||
|
||||
:::warning
|
||||
🚧 WXT does not currently support automated publishing for Safari. Safari extensions require a native MacOS or iOS app wrapper, which WXT cannot create at this time. For now, if you want to publish to Safari, follow this guide:
|
||||
|
||||
https://developer.apple.com/documentation/safariservices/safari_web_extensions/distributing_your_safari_web_extension
|
||||
If you use a `.env` files, they can effect the chunk hashes in the output directory. Either delete the .env file before running `wxt zip -b firefox`, or include it in your sources zip with the [`zip.includeSources`](/api/wxt/interfaces/InlineConfig#includesources) option. Be careful to not include any secrets in your `.env` files.
|
||||
|
||||
See Issue [#377](https://github.com/wxt-dev/wxt/issues/377) for more details.
|
||||
:::
|
||||
|
||||
## Edge Addons
|
||||
Ensure that you have a `README.md` or `SOURCE_CODE_REVIEW.md` file with the above commands so that the Firefox team knows how to build your extension.
|
||||
|
||||
✅ Automated • [Developer Dashboard](https://aka.ms/PartnerCenterLogin) • [Publishing Docs](https://learn.microsoft.com/en-us/microsoft-edge/extensions-chromium/publish/publish-extension)
|
||||
### Safari
|
||||
|
||||
> 🚧 Not supported yet
|
||||
|
||||
WXT does not currently support automated publishing for Safari. Safari extensions require a native MacOS or iOS app wrapper, which WXT does not create yet. For now, if you want to publish to Safari, follow this guide:
|
||||
|
||||
- [Converting a web extension for Safari](https://developer.apple.com/documentation/safariservices/safari_web_extensions/converting_a_web_extension_for_safari) - "Convert your existing extension to a Safari web extension using Xcode’s command-line tool."
|
||||
|
||||
When running the `safari-web-extension-converter` CLI tool, pass the `.output/safari-mv2` or `.output/safari-mv3` directory, not your source code directory.
|
||||
|
||||
```sh
|
||||
pnpm wxt build -b safari
|
||||
xcrun safari-web-extension-converter .output/safari-mv2
|
||||
```
|
||||
|
||||
### Edge Addons
|
||||
|
||||
> ✅ Supported • [Developer Dashboard](https://aka.ms/PartnerCenterLogin) • [Publishing Docs](https://learn.microsoft.com/en-us/microsoft-edge/extensions-chromium/publish/publish-extension)
|
||||
|
||||
No need to create a specific ZIP for Edge. If you're already publishing to the Chrome Web Store, you can reuse your Chrome ZIP.
|
||||
|
||||
|
||||
+246
-28
@@ -1,36 +1,254 @@
|
||||
# Storage API
|
||||
|
||||
WXT's storage API is powered by `unstorage`. See [their docs](https://unstorage.unjs.io/usage#usage-1) for more details.
|
||||
|
||||
## Overview
|
||||
|
||||
:::code-group
|
||||
|
||||
```ts [native]
|
||||
const { installDate } = await browser.storage.local.get('installDate');
|
||||
await browser.storage.local.set({ key: 'value' });
|
||||
```
|
||||
|
||||
```ts [wxt/browser]
|
||||
const installDate = await storage.get('local:installDate');
|
||||
await storage.setItem('key', 'value');
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
Use the `"local:"`, `"session:"`, `"sync:"`, and `"managed:"` prefixes to specify which storage area to use.
|
||||
|
||||
## Customization
|
||||
|
||||
WXT also provides a driver for `unstorage`. To customize the `storage` object's setup, like removing the prefixes and using a single storage area, you can create your own storage:
|
||||
WXT provides a simplified API to replace the `browser.storage.*` APIs. Use the `storage` auto-import from `wxt/storage` or import it manually to get started:
|
||||
|
||||
```ts
|
||||
// storage.ts
|
||||
export default createStorage({
|
||||
driver: webExtensionDriver({ storageArea: 'local' }),
|
||||
import { storage } from 'wxt/storage';
|
||||
```
|
||||
|
||||
[[toc]]
|
||||
|
||||
## Basic Usage
|
||||
|
||||
All storage keys must be prefixed by their storage area.
|
||||
|
||||
```ts
|
||||
// ❌ This will throw an error
|
||||
await storage.getItem('installDate');
|
||||
|
||||
// ✅ This is good
|
||||
await storage.getItem('local:installDate');
|
||||
```
|
||||
|
||||
You can use `local:`, `session:`, `sync:`, or `managed:`.
|
||||
|
||||
If you use TypeScript, you can add a type parameter to most methods to specify the expected type of the key's value:
|
||||
|
||||
```ts
|
||||
await storage.getItem<number>('local:installDate');
|
||||
await storage.watch<number>(
|
||||
'local:installDate',
|
||||
(newInstallDate, oldInstallDate) => {
|
||||
// ...
|
||||
},
|
||||
);
|
||||
await storage.getMeta<{ v: number }>('local:installDate');
|
||||
```
|
||||
|
||||
## Watchers
|
||||
|
||||
To listen for storage changes, use the `storage.watch` function. It lets you setup a listener for a single key:
|
||||
|
||||
```ts
|
||||
const unwatch = storage.watch<number>('local:counter', (newCount, oldCount) => {
|
||||
console.log('Count changed:', { newCount, oldCount });
|
||||
});
|
||||
```
|
||||
|
||||
:::note
|
||||
`wxt/browser` re-exports all of `unstorage`, which is where `createStorage` comes from.
|
||||
To remove the listener, call the returned `unwatch` function:
|
||||
|
||||
```ts
|
||||
const unwatch = storage.watch(...);
|
||||
|
||||
// Some time later...
|
||||
unwatch();
|
||||
```
|
||||
|
||||
## Metadata
|
||||
|
||||
`wxt/storage` also supports setting metadata for keys, stored at `key + "$"`. Metadata is a collection of properties associated with a key. It might be a version number, last modified date, etc.
|
||||
|
||||
[Other than versioning](#versioning-and-migrations), you are responsible for managing a field's metadata:
|
||||
|
||||
```ts
|
||||
await Promise.all([
|
||||
storage.setItem('local:preference', true),
|
||||
storage.setMeta('local:preference', { lastModified: Date.now() }),
|
||||
]);
|
||||
```
|
||||
|
||||
When setting different properties of metadata from multiple calls, the properties are combined instead of overwritten:
|
||||
|
||||
```ts
|
||||
await storage.setMeta('local:preference', { lastModified: Date.now() });
|
||||
await storage.setMeta('local:preference', { v: 2 });
|
||||
|
||||
await storage.getMeta('local:preference'); // { v: 2, lastModified: 1703690746007 }
|
||||
```
|
||||
|
||||
You can remove all metadata associated with a key, or just specific properties:
|
||||
|
||||
```ts
|
||||
// Remove all properties
|
||||
await storage.removeMeta('local:preference');
|
||||
|
||||
// Remove one property
|
||||
await storage.removeMeta('local:preference', 'lastModified');
|
||||
|
||||
// Remove multiple properties
|
||||
await storage.removeMeta('local:preference', ['lastModified', 'v']);
|
||||
```
|
||||
|
||||
## Defining Storage Items
|
||||
|
||||
Writing the key and type parameter for the same key over and over again can be annoying. As an alternative, you can use `storage.defineItem` to create a "storage item".
|
||||
|
||||
Storage items contain the same APIs as the `storage` variable, but you can configure its type, default value, and more in a single place:
|
||||
|
||||
```ts
|
||||
// utils/storage.ts
|
||||
const showChangelogOnUpdate = storage.defineItem<boolean>(
|
||||
'local:showChangelogOnUpdate',
|
||||
{
|
||||
defaultValue: true,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Now, instead of using the `storage` variable, you can use the helper functions on the storage item you created:
|
||||
|
||||
```ts
|
||||
await showChangelogOnUpdate.getValue();
|
||||
await showChangelogOnUpdate.setValue(false);
|
||||
await showChangelogOnUpdate.removeValue();
|
||||
const unwatch = showChangelogOnUpdate.watch(() => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Versioning and Migrations
|
||||
|
||||
You can add versioning to storage items if you expect them to grow or change over time. When defining the first version of an item, start with version 1.
|
||||
|
||||
For example, consider a storage item that stores a list of websites that are ignored by an extension.
|
||||
|
||||
:::code-group
|
||||
|
||||
```ts [v1]
|
||||
type IgnoredWebsiteV1 = string;
|
||||
|
||||
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV1[]>(
|
||||
'local:ignoredWebsites',
|
||||
{
|
||||
defaultValue: [],
|
||||
version: 1,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
```ts [v2]
|
||||
import { nanoid } from 'nanoid'; // [!code ++]
|
||||
|
||||
type IgnoredWebsiteV1 = string;
|
||||
interface IgnoredWebsiteV2 { // [!code ++]
|
||||
id: string; // [!code ++]
|
||||
website: string; // [!code ++]
|
||||
} // [!code ++]
|
||||
|
||||
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV1[]>( // [!code --]
|
||||
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV2[]>( // [!code ++]
|
||||
'local:ignoredWebsites',
|
||||
{
|
||||
defaultValue: [],
|
||||
version: 1, // [!code --]
|
||||
version: 2, // [!code ++]
|
||||
migrations: { // [!code ++]
|
||||
// Ran when migrating from v1 to v2 // [!code ++]
|
||||
2: (websites: IgnoredWebsiteV1[]): IgnoredWebsiteV2[] => { // [!code ++]
|
||||
return websites.map((website) => ({ id: nanoid(), website })); // [!code ++]
|
||||
}, // [!code ++]
|
||||
}, // [!code ++]
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
```ts [v3]
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
type IgnoredWebsiteV1 = string;
|
||||
interface IgnoredWebsiteV2 {
|
||||
id: string;
|
||||
website: string;
|
||||
}
|
||||
interface IgnoredWebsiteV3 { // [!code ++]
|
||||
id: string; // [!code ++]
|
||||
website: string; // [!code ++]
|
||||
enabled: boolean; // [!code ++]
|
||||
} // [!code ++]
|
||||
|
||||
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV2[]>( // [!code --]
|
||||
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV3[]>( // [!code ++]
|
||||
'local:ignoredWebsites',
|
||||
{
|
||||
defaultValue: [],
|
||||
version: 2, // [!code --]
|
||||
version: 3, // [!code ++]
|
||||
migrations: {
|
||||
// Ran when migrating from v1 to v2
|
||||
2: (websites: IgnoredWebsiteV1[]): IgnoredWebsiteV2[] => {
|
||||
return websites.map((website) => ({ id: nanoid(), website }));
|
||||
},
|
||||
// Ran when migrating from v2 to v3 // [!code ++]
|
||||
3: (websites: IgnoredWebsiteV2[]): IgnoredWebsiteV3[] => { // [!code ++]
|
||||
return websites.map((website) => ({ ...website, enabled: true })); // [!code ++]
|
||||
}, // [!code ++]
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
:::info
|
||||
Internally, this uses a metadata property called `v` to track the value's current version.
|
||||
:::
|
||||
|
||||
In this case, we thought that the ignored website list might change in the future, and were able to setup a versioned storage item from the start.
|
||||
|
||||
Realistically, you won't know a item needs versioned until you need to change it's schema. Thankfully, it's simple to add versioning to an unversioned storage item.
|
||||
|
||||
When a previous version isn't found, WXT assumes the version was `1`. That means you just need to set `version: 2` and add a migration for `2`, and it will just work!
|
||||
|
||||
Lets look at the same ignored websites example from before, but start with an unversioned item this time:
|
||||
|
||||
:::code-group
|
||||
|
||||
```ts [Unversioned]
|
||||
export const ignoredWebsites = storage.defineItem<string[]>(
|
||||
'local:ignoredWebsites',
|
||||
{
|
||||
defaultValue: [],
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
```ts [v2]
|
||||
import { nanoid } from 'nanoid'; // [!code ++]
|
||||
|
||||
// Retroactively add a type for the first version // [!code ++]
|
||||
type IgnoredWebsiteV1 = string; // [!code ++]
|
||||
interface IgnoredWebsiteV2 { // [!code ++]
|
||||
id: string; // [!code ++]
|
||||
website: string; // [!code ++]
|
||||
} // [!code ++]
|
||||
|
||||
export const ignoredWebsites = storage.defineItem<string[]>( // [!code --]
|
||||
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV2[]>( // [!code ++]
|
||||
'local:ignoredWebsites',
|
||||
{
|
||||
defaultValue: [],
|
||||
version: 2, // [!code ++]
|
||||
migrations: { // [!code ++]
|
||||
// Ran when migrating from v1 to v2 // [!code ++]
|
||||
2: (websites: IgnoredWebsiteV1[]): IgnoredWebsiteV2[] => { // [!code ++]
|
||||
return websites.map((website) => ({ id: nanoid(), website })); // [!code ++]
|
||||
}, // [!code ++]
|
||||
}, // [!code ++]
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
+14
-151
@@ -1,162 +1,25 @@
|
||||
# Testing
|
||||
|
||||
WXT provides several utils for writing tests.
|
||||
## Official Frameworks
|
||||
|
||||
## Unit tests
|
||||
WXT officially supports [Vitest](https://vitest.dev/) for unit tests and either [Playwright](https://playwright.dev/) or [Puppeteer](https://pptr.dev/) for E2E tests against Chromium browsers.
|
||||
|
||||
If you're using auto-imports (enabled by default), [Vitest](https://vitest.dev/) is the only testing framework that supports them.
|
||||
For details setting up each testing framework, see the official examples:
|
||||
|
||||
If you want to use a different testing library/framework (like Jest, mocha, node:test, etc), you can keep using it, but you have two options:
|
||||
<ExampleList tag="testing" />
|
||||
|
||||
1. Switch to Vitest (recommended)
|
||||
2. Configure the testing library manually
|
||||
- Disable auto-imports by setting `imports: false` in your `wxt.config.ts` file
|
||||
- Manually add globals normally provided by WXT (like `__BROWSER__`) that you consume to the global scope before accessing them (`globalThis.__BROWSER__ = "chrome"`)
|
||||
### Unofficial Frameworks
|
||||
|
||||
### Vitest Setup
|
||||
Puppeteer and Playwright are the only E2E test runners that support Chrome Extensions. There are no other options at the time of writing.
|
||||
|
||||
Install vitest and add the `WxtVitest` plugin to your `vitest.config.ts` file.
|
||||
There are other options for unit tests however, like [Jest](https://jestjs.io/), [Mocha](https://mochajs.org/), or [`node:test`](https://nodejs.org/api/test.html). **_WXT does not claim to support any of them_** because none of them support all of WXT's features, like TypeScript or auto-imports.
|
||||
|
||||
```sh
|
||||
pnpm i -D vitest
|
||||
```
|
||||
If you want to try to use a different framework for unit tests, you will need to configure the environment manually:
|
||||
|
||||
```ts
|
||||
// <root>/vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { WxtVitest } from 'wxt/testing';
|
||||
- **Auto-imports**: Add `unimport` to your test environment or disable them by setting `imports: false` in your `wxt.config.ts` file
|
||||
- **`browser` mock**: Mock the `webextension-polyfill` module globally with `wxt/dist/virtual/mock-browser.js`
|
||||
- **[Remote Code Bundling](/guide/remote-code)**: If you use it, configure your environment to handle the `url:` module prefix
|
||||
- **Global Variables**: If you consume them, manually define globals provided by WXT (like `import.meta.env.BROWSER`) by adding them to the global scope before accessing them (`import.meta.env.BROWSER = "chrome"`)
|
||||
- **Import paths**: If you use the `@/` or `~/` path aliases, add them to your test environment
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [WxtVitest()],
|
||||
test: {
|
||||
server: {
|
||||
deps: {
|
||||
// Add any dependencies that import webextension-polyfill here, otherwise tests will attempt to import the real polyfill, breaking the
|
||||
// TODO: Auto-detect these dependencies inside `WxtVitest` so maintaining this list manually isn't necessary
|
||||
inline: [...],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
And that's it. You're ready to start writing tests.
|
||||
|
||||
### Writing Tests
|
||||
|
||||
Here's a very basic test, written with a few different testing libraries, with a few different approaches for mocking the `browser` global.
|
||||
|
||||
:::code-group
|
||||
|
||||
```ts [Vitest]
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
function logRuntimeId() {
|
||||
// Vitest automatically mocks "browser" with "fakeBrowser"
|
||||
console.log(browser.runtime.id);
|
||||
}
|
||||
|
||||
describe('logRuntimeId', () => {
|
||||
it("should log the extension's runtime ID", () => {
|
||||
// Set a known ID on fakeBrowser for the test
|
||||
const id = 'some-runtime-id';
|
||||
fakeBrowser.runtime.id = id;
|
||||
const logSpy = vi.spyOn(console, 'log');
|
||||
|
||||
logRuntimeId();
|
||||
|
||||
expect(logSpy).toBeCalledWith(id);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```ts [Jest - Manual Mock]
|
||||
import { fakeBrowser } from 'wxt/testing';
|
||||
import { browser } from 'wxt/browser';
|
||||
|
||||
function logRuntimeId() {
|
||||
console.log(browser.runtime.id);
|
||||
}
|
||||
|
||||
// Manually mock
|
||||
jest.mock('wxt/browser', () => {
|
||||
const { fakeBrowser } = require('wxt/testing');
|
||||
return { browser: fakeBrowser };
|
||||
});
|
||||
|
||||
describe('logRuntimeId', () => {
|
||||
it("should log the extension's runtime ID", () => {
|
||||
// Set a known ID on fakeBrowser for the test
|
||||
const id = 'some-runtime-id';
|
||||
fakeBrowser.runtime.id = id;
|
||||
const logSpy = jest.spyOn(console, 'log');
|
||||
|
||||
logRuntimeId();
|
||||
|
||||
expect(logSpy).toBeCalledWith(id);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```ts [node:test - Parameterized]
|
||||
import { describe, it, mock } from 'node:test';
|
||||
import { assert } from 'node:assert';
|
||||
import { fakeBrowser } from 'wxt/testing';
|
||||
import { browser } from 'wxt/browser';
|
||||
|
||||
// Add browser as a parameter so fakeBrowser can be passed instead of browser
|
||||
function logRuntimeId(browser = browser) {
|
||||
console.log(browser.runtime.id);
|
||||
}
|
||||
|
||||
describe('logRuntimeId', () => {
|
||||
it("should log the extension's runtime ID", () => {
|
||||
// Set a known ID on fakeBrowser for the test
|
||||
const id = 'some-runtime-id';
|
||||
fakeBrowser.runtime.id = id;
|
||||
console.log = mock.fn();
|
||||
|
||||
// pass in fakeBrowser during tests
|
||||
logRuntimeId(fakeBrowser);
|
||||
|
||||
assert.deepStrictEqual(console.log.mock.calls[0].arguments, [id]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
:::warning
|
||||
Without mocking the `browser` variable, you'll see errors like this:
|
||||
|
||||
```
|
||||
This script should only be loaded in a browser extension.
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
WXT provides an in-memory, partial implementation of `browser`, [`fakeBrowser`](/api/wxt/testing/variables/fakeBrowser), from the [`@webext-core/fake-browser`](https://webext-core.aklinker1.io/guide/fake-browser/) package. `fakeBrowser` works with all testing frameworks/libraries. See their docs for a list of [implemented APIs](https://webext-core.aklinker1.io/guide/fake-browser/implemented-apis.html) and more example tests.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
WXT does not provide any utils for running E2E tests. There are two libraries you can use to run E2E tests for any chrome extension.
|
||||
|
||||
- [`playwright`](https://playwright.dev/docs/chrome-extensions) (recommended) - "A high-level API to automate web browsers"
|
||||
- [`puppeteer`](https://pptr.dev/guides/chrome-extensions) - "A high-level API to control headless Chrome over the DevTools Protocol"
|
||||
|
||||
:::info
|
||||
Note that both only support running tests on Chrome.
|
||||
:::
|
||||
|
||||
Before running tests with either of these tools, you must build the extension with `wxt build` and then load the extension from the output directory in a new tab.
|
||||
|
||||
To test an extension's UI, like the popup or options page, you'll need to know the extension's ID to open the URL directly.
|
||||
|
||||
> _chrome-extension://`browser.runtime.id`/popup.html_
|
||||
|
||||
- Playwright provides an API to get your extension ID after it has been installed. [See their docs](https://playwright.dev/docs/chrome-extensions#testing).
|
||||
- Puppeteer requires you know the ID before installing the extension, so you can hard code it into the URLs you open. Follow [Chrome's guide](https://developer.chrome.com/docs/extensions/mv3/manifest/key/) to setup a consistent runtime id.
|
||||
|
||||
:::info
|
||||
You cannot test popups in their normal popup window, you have to open them in a tab.
|
||||
:::
|
||||
[Here's how Vitest is configured](https://github.com/wxt-dev/wxt/blob/main/src/testing/wxt-vitest-plugin.ts) for reference.
|
||||
|
||||
+8
-8
@@ -47,11 +47,9 @@ features:
|
||||
details: Nuxt-like auto-imports to speed up development.
|
||||
link: /guide/auto-imports
|
||||
linkText: Read docs
|
||||
- icon: ⬇️
|
||||
title: Bundle Remote Code
|
||||
details: Downloads and bundles remote code imported from URLs.
|
||||
link: /guide/remote-code
|
||||
linkText: Read docs
|
||||
- icon: 🤖
|
||||
title: Automated Publishing
|
||||
details: Automatically zip, upload, submit, and publish extensions.
|
||||
- icon: 🎨
|
||||
title: Frontend Framework Agnostic
|
||||
details: Works with any front-end framework with a Vite plugin.
|
||||
@@ -65,9 +63,11 @@ features:
|
||||
- icon: 📏
|
||||
title: Bundle Analysis
|
||||
details: Tools for analyizing the final extension bundle and minimizing your extension's size.
|
||||
- icon: 🤖
|
||||
title: Automated Publishing
|
||||
details: 'Coming soon. Automatically zip, upload, and release extensions.'
|
||||
- icon: ⬇️
|
||||
title: Bundle Remote Code
|
||||
details: Downloads and bundles remote code imported from URLs.
|
||||
link: /guide/remote-code
|
||||
linkText: Read docs
|
||||
---
|
||||
|
||||
<section class="vp-doc">
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { TestProject } from '../utils';
|
||||
import { resetBundleIncrement } from '~/core/builders/vite/plugins';
|
||||
|
||||
describe('Analysis', () => {
|
||||
beforeEach(() => {
|
||||
resetBundleIncrement();
|
||||
});
|
||||
|
||||
it('should outptut a stats.html with no part files by default', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/popup.html');
|
||||
project.addFile('entrypoints/options.html');
|
||||
project.addFile(
|
||||
'entrypoints/background.ts',
|
||||
'export default defineBackground(() => {});',
|
||||
);
|
||||
|
||||
await project.build({
|
||||
analysis: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await project.fileExists('stats.html')).toBe(true);
|
||||
expect(await project.fileExists('.output/chrome-mv3/stats-0.json')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should save part files when requested', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/popup.html');
|
||||
project.addFile('entrypoints/options.html');
|
||||
project.addFile(
|
||||
'entrypoints/background.ts',
|
||||
'export default defineBackground(() => {});',
|
||||
);
|
||||
|
||||
await project.build({
|
||||
analysis: {
|
||||
enabled: true,
|
||||
keepArtifacts: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await project.fileExists('stats.html')).toBe(true);
|
||||
expect(await project.fileExists('stats-0.json')).toBe(true);
|
||||
expect(await project.fileExists('stats-1.json')).toBe(true);
|
||||
});
|
||||
|
||||
it('should support customizing the stats output directory', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/popup.html');
|
||||
project.addFile('entrypoints/options.html');
|
||||
project.addFile(
|
||||
'entrypoints/background.ts',
|
||||
'export default defineBackground(() => {});',
|
||||
);
|
||||
|
||||
await project.build({
|
||||
analysis: {
|
||||
enabled: true,
|
||||
outputFile: 'stats/bundle.html',
|
||||
},
|
||||
});
|
||||
|
||||
expect(await project.fileExists('stats/bundle.html')).toBe(true);
|
||||
});
|
||||
|
||||
it('should place artifacts next to the custom output file', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/popup.html');
|
||||
project.addFile('entrypoints/options.html');
|
||||
project.addFile(
|
||||
'entrypoints/background.ts',
|
||||
'export default defineBackground(() => {});',
|
||||
);
|
||||
|
||||
await project.build({
|
||||
analysis: {
|
||||
enabled: true,
|
||||
outputFile: 'stats/bundle.html',
|
||||
keepArtifacts: true,
|
||||
},
|
||||
});
|
||||
console.log(project.root);
|
||||
|
||||
expect(await project.fileExists('stats/bundle.html')).toBe(true);
|
||||
expect(await project.fileExists('stats/bundle-0.json')).toBe(true);
|
||||
expect(await project.fileExists('stats/bundle-1.json')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -17,25 +17,18 @@ describe('Auto Imports', () => {
|
||||
export {}
|
||||
declare global {
|
||||
const ContentScriptContext: typeof import('wxt/client')['ContentScriptContext']
|
||||
const InvalidMatchPattern: typeof import('wxt/sandbox')['InvalidMatchPattern']
|
||||
const MatchPattern: typeof import('wxt/sandbox')['MatchPattern']
|
||||
const browser: typeof import('wxt/browser')['browser']
|
||||
const builtinDrivers: typeof import('wxt/storage')['builtinDrivers']
|
||||
const createContentScriptIframe: typeof import('wxt/client')['createContentScriptIframe']
|
||||
const createContentScriptUi: typeof import('wxt/client')['createContentScriptUi']
|
||||
const createStorage: typeof import('wxt/storage')['createStorage']
|
||||
const defineBackground: typeof import('wxt/client')['defineBackground']
|
||||
const createIframeUi: typeof import('wxt/client')['createIframeUi']
|
||||
const createIntegratedUi: typeof import('wxt/client')['createIntegratedUi']
|
||||
const createShadowRootUi: typeof import('wxt/client')['createShadowRootUi']
|
||||
const defineBackground: typeof import('wxt/sandbox')['defineBackground']
|
||||
const defineConfig: typeof import('wxt')['defineConfig']
|
||||
const defineContentScript: typeof import('wxt/client')['defineContentScript']
|
||||
const defineDriver: typeof import('wxt/storage')['defineDriver']
|
||||
const defineContentScript: typeof import('wxt/sandbox')['defineContentScript']
|
||||
const defineUnlistedScript: typeof import('wxt/sandbox')['defineUnlistedScript']
|
||||
const fakeBrowser: typeof import('wxt/testing')['fakeBrowser']
|
||||
const joinKeys: typeof import('wxt/storage')['joinKeys']
|
||||
const normalizeBaseKey: typeof import('wxt/storage')['normalizeBaseKey']
|
||||
const normalizeKey: typeof import('wxt/storage')['normalizeKey']
|
||||
const prefixStorage: typeof import('wxt/storage')['prefixStorage']
|
||||
const restoreSnapshot: typeof import('wxt/storage')['restoreSnapshot']
|
||||
const snapshot: typeof import('wxt/storage')['snapshot']
|
||||
const storage: typeof import('wxt/storage')['storage']
|
||||
const webExtensionDriver: typeof import('wxt/storage')['webExtensionDriver']
|
||||
}
|
||||
"
|
||||
`);
|
||||
@@ -49,16 +42,16 @@ describe('Auto Imports', () => {
|
||||
|
||||
expect(await project.serializeFile('.wxt/wxt.d.ts'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".wxt/wxt.d.ts
|
||||
----------------------------------------
|
||||
// Generated by wxt
|
||||
/// <reference types=\\"vite/client\\" />
|
||||
/// <reference types=\\"./types/imports.d.ts\\" />
|
||||
/// <reference types=\\"./types/paths.d.ts\\" />
|
||||
/// <reference types=\\"./types/i18n.d.ts\\" />
|
||||
/// <reference types=\\"./types/globals.d.ts\\" />
|
||||
"
|
||||
`);
|
||||
".wxt/wxt.d.ts
|
||||
----------------------------------------
|
||||
// Generated by wxt
|
||||
/// <reference types="wxt/vite-builder-env" />
|
||||
/// <reference types="./types/imports.d.ts" />
|
||||
/// <reference types="./types/paths.d.ts" />
|
||||
/// <reference types="./types/i18n.d.ts" />
|
||||
/// <reference types="./types/globals.d.ts" />
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,10 +84,10 @@ describe('Auto Imports', () => {
|
||||
".wxt/wxt.d.ts
|
||||
----------------------------------------
|
||||
// Generated by wxt
|
||||
/// <reference types=\\"vite/client\\" />
|
||||
/// <reference types=\\"./types/paths.d.ts\\" />
|
||||
/// <reference types=\\"./types/i18n.d.ts\\" />
|
||||
/// <reference types=\\"./types/globals.d.ts\\" />
|
||||
/// <reference types="wxt/vite-builder-env" />
|
||||
/// <reference types="./types/paths.d.ts" />
|
||||
/// <reference types="./types/i18n.d.ts" />
|
||||
/// <reference types="./types/globals.d.ts" />
|
||||
"
|
||||
`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { TestProject } from '../utils';
|
||||
import { WxtHooks } from '~/types';
|
||||
|
||||
const hooks: WxtHooks = {
|
||||
ready: vi.fn(),
|
||||
'build:before': vi.fn(),
|
||||
'build:done': vi.fn(),
|
||||
'build:manifestGenerated': vi.fn(),
|
||||
'entrypoints:resolved': vi.fn(),
|
||||
'entrypoints:grouped': vi.fn(),
|
||||
};
|
||||
|
||||
function expectHooksToBeCalled(called: Record<keyof WxtHooks, boolean>) {
|
||||
Object.keys(hooks).forEach((key) => {
|
||||
const hookName = key as keyof WxtHooks;
|
||||
const times = called[hookName] ? 1 : 0;
|
||||
expect(
|
||||
hooks[hookName],
|
||||
`Expected "${hookName}" to be called ${times} time(s)`,
|
||||
).toBeCalledTimes(called[hookName] ? 1 : 0);
|
||||
});
|
||||
}
|
||||
|
||||
describe('Hooks', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hooks).forEach((fn) => fn.mockReset());
|
||||
});
|
||||
|
||||
it('prepare should call hooks', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/popup.html', '<html></html>');
|
||||
|
||||
await project.prepare({ hooks });
|
||||
|
||||
expectHooksToBeCalled({
|
||||
ready: true,
|
||||
'build:before': false,
|
||||
'build:done': false,
|
||||
'build:manifestGenerated': false,
|
||||
'entrypoints:grouped': false,
|
||||
'entrypoints:resolved': true,
|
||||
});
|
||||
});
|
||||
|
||||
it('build should call hooks', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/popup.html', '<html></html>');
|
||||
|
||||
await project.build({ hooks });
|
||||
|
||||
expectHooksToBeCalled({
|
||||
ready: true,
|
||||
'build:before': true,
|
||||
'build:done': true,
|
||||
'build:manifestGenerated': true,
|
||||
'entrypoints:grouped': true,
|
||||
'entrypoints:resolved': true,
|
||||
});
|
||||
});
|
||||
|
||||
it('zip should call hooks', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/popup.html', '<html></html>');
|
||||
|
||||
await project.zip({ hooks });
|
||||
|
||||
expectHooksToBeCalled({
|
||||
ready: true,
|
||||
'build:before': true,
|
||||
'build:done': true,
|
||||
'build:manifestGenerated': true,
|
||||
'entrypoints:grouped': true,
|
||||
'entrypoints:resolved': true,
|
||||
});
|
||||
});
|
||||
|
||||
it('server.start should call hooks', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/popup.html', '<html></html>');
|
||||
|
||||
const server = await project.startServer({
|
||||
hooks,
|
||||
runner: {
|
||||
disabled: true,
|
||||
},
|
||||
});
|
||||
await server.stop();
|
||||
|
||||
expectHooksToBeCalled({
|
||||
ready: true,
|
||||
'build:before': true,
|
||||
'build:done': true,
|
||||
'build:manifestGenerated': true,
|
||||
'entrypoints:grouped': true,
|
||||
'entrypoints:resolved': true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,552 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TestProject } from '../utils';
|
||||
|
||||
// TODO: move to unit tests to speed this up - this doesn't have to be in E2E tests
|
||||
|
||||
describe('Manifest Content', () => {
|
||||
describe('popup', () => {
|
||||
const popupContent = (type?: 'browser_action' | 'page_action') => `
|
||||
<html>
|
||||
<head>
|
||||
${type == null ? '' : `<meta name="manifest.type" content="${type}">`}
|
||||
<meta name="manifest.default_icon" content="{ '16': '/icon/16.png' }">
|
||||
<title>Default Title</title>
|
||||
</head>
|
||||
</html>
|
||||
`;
|
||||
|
||||
it('should include an action for mv3', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/popup.html', popupContent());
|
||||
|
||||
await project.build();
|
||||
|
||||
const manifest = await project.getOutputManifest();
|
||||
expect(manifest.action).toEqual({
|
||||
default_icon: { '16': '/icon/16.png' },
|
||||
default_title: 'Default Title',
|
||||
default_popup: 'popup.html',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, 'browser_action'],
|
||||
['browser_action', 'browser_action'],
|
||||
['page_action', 'page_action'],
|
||||
] as const)(
|
||||
'should include a browser_action for mv2',
|
||||
async (type, expectedType) => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/popup.html', popupContent(type));
|
||||
|
||||
await project.build({ manifestVersion: 2 });
|
||||
|
||||
const manifest = await project.getOutputManifest(
|
||||
'.output/chrome-mv2/manifest.json',
|
||||
);
|
||||
expect(manifest[expectedType]).toEqual({
|
||||
default_icon: { '16': '/icon/16.png' },
|
||||
default_title: 'Default Title',
|
||||
default_popup: 'popup.html',
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('options', () => {
|
||||
const optionsContent = `
|
||||
<html>
|
||||
<head>
|
||||
<meta name="manifest.open_in_tab" content="false">
|
||||
<meta name="manifest.chrome_style" content="true">
|
||||
<meta name="manifest.browser_style" content="true">
|
||||
</head>
|
||||
</html>
|
||||
`;
|
||||
|
||||
it('should include a options_ui and chrome_style for chrome', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/options.html', optionsContent);
|
||||
|
||||
await project.build();
|
||||
const manifest = await project.getOutputManifest();
|
||||
|
||||
expect(manifest.options_ui).toEqual({
|
||||
open_in_tab: false,
|
||||
chrome_style: true,
|
||||
page: 'options.html',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include a options_ui and browser_style for firefox', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/options.html', optionsContent);
|
||||
|
||||
await project.build({ browser: 'firefox' });
|
||||
const manifest = await project.getOutputManifest(
|
||||
'.output/firefox-mv2/manifest.json',
|
||||
);
|
||||
|
||||
expect(manifest.options_ui).toEqual({
|
||||
open_in_tab: false,
|
||||
browser_style: true,
|
||||
page: 'options.html',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('background', () => {
|
||||
const backgroundContent = `
|
||||
export default defineBackground({
|
||||
persistent: true,
|
||||
type: "module",
|
||||
main: () => {},
|
||||
})
|
||||
`;
|
||||
|
||||
it.each(['chrome', 'safari'])(
|
||||
'should include scripts and persistent for %s mv2',
|
||||
async (browser) => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/background.ts', backgroundContent);
|
||||
|
||||
await project.build({ browser, manifestVersion: 2 });
|
||||
const manifest = await project.getOutputManifest(
|
||||
`.output/${browser}-mv2/manifest.json`,
|
||||
);
|
||||
|
||||
expect(manifest.background).toEqual({
|
||||
persistent: true,
|
||||
scripts: ['background.js'],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['chrome', 'safari'])(
|
||||
'should include a service worker and type for %s mv3',
|
||||
async (browser) => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/background.ts', backgroundContent);
|
||||
|
||||
await project.build({ browser, manifestVersion: 3 });
|
||||
const manifest = await project.getOutputManifest(
|
||||
`.output/${browser}-mv3/manifest.json`,
|
||||
);
|
||||
|
||||
expect(manifest.background).toEqual({
|
||||
type: 'module',
|
||||
service_worker: 'background.js',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('should include a background script and type for firefox mv3', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/background.ts', backgroundContent);
|
||||
|
||||
await project.build({ browser: 'firefox', manifestVersion: 3 });
|
||||
const manifest = await project.getOutputManifest(
|
||||
'.output/firefox-mv3/manifest.json',
|
||||
);
|
||||
|
||||
expect(manifest.background).toEqual({
|
||||
type: 'module',
|
||||
scripts: ['background.js'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should include a background script and persistent for firefox mv2', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/background.ts', backgroundContent);
|
||||
|
||||
await project.build({ browser: 'firefox', manifestVersion: 2 });
|
||||
const manifest = await project.getOutputManifest(
|
||||
'.output/firefox-mv2/manifest.json',
|
||||
);
|
||||
|
||||
expect(manifest.background).toEqual({
|
||||
persistent: true,
|
||||
scripts: ['background.js'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('icons', () => {
|
||||
it('should auto-discover icons with the correct name', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('public/icon-16.png');
|
||||
project.addFile('public/icon/32.png');
|
||||
project.addFile('public/icon@48w.png');
|
||||
project.addFile('public/icon-64x64.png');
|
||||
project.addFile('public/icon@96.png');
|
||||
project.addFile('public/icons/128x128.png');
|
||||
|
||||
await project.build();
|
||||
const manifest = await project.getOutputManifest();
|
||||
|
||||
expect(manifest.icons).toEqual({
|
||||
'16': 'icon-16.png',
|
||||
'32': 'icon/32.png',
|
||||
'48': 'icon@48w.png',
|
||||
'64': 'icon-64x64.png',
|
||||
'96': 'icon@96.png',
|
||||
'128': 'icons/128x128.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when no icons are found', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('public/logo.png');
|
||||
project.addFile('public/icon.jpeg');
|
||||
|
||||
await project.build();
|
||||
const manifest = await project.getOutputManifest();
|
||||
|
||||
expect(manifest.icons).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow icons to be overwritten from the wxt.config.ts file', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('public/icon-16.png');
|
||||
project.addFile('public/icon-32.png');
|
||||
project.addFile('public/logo-16.png');
|
||||
project.addFile('public/logo-32.png');
|
||||
project.addFile('public/logo-48.png');
|
||||
|
||||
const icons = {
|
||||
'16': 'logo-16.png',
|
||||
'32': 'logo-32.png',
|
||||
'48': 'logo-48.png',
|
||||
};
|
||||
project.setConfigFileConfig({
|
||||
manifest: {
|
||||
icons,
|
||||
},
|
||||
});
|
||||
|
||||
await project.build();
|
||||
const manifest = await project.getOutputManifest();
|
||||
|
||||
expect(manifest.icons).toEqual(icons);
|
||||
});
|
||||
});
|
||||
|
||||
describe('content_scripts', () => {
|
||||
it('should group content scripts and styles together based on their manifest properties', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/one.content/index.ts',
|
||||
`import "./style.css";
|
||||
export default defineContentScript({
|
||||
matches: ["*://google.com/*"],
|
||||
main: () => {},
|
||||
})`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/one.content/style.css',
|
||||
`body { color: red }`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/two.content/index.ts',
|
||||
`import "./style.css";
|
||||
export default defineContentScript({
|
||||
matches: ["*://google.com/*"],
|
||||
runAt: "document_end",
|
||||
main: () => {},
|
||||
})`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/two.content/style.css',
|
||||
`body { color: green }`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/three.content/index.ts',
|
||||
`import "./style.css";
|
||||
export default defineContentScript({
|
||||
matches: ["*://google.com/*"],
|
||||
runAt: "document_end",
|
||||
main: () => {},
|
||||
})`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/three.content/style.css',
|
||||
`body { color: blue }`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/four.content/index.ts',
|
||||
`import "./style.css";
|
||||
export default defineContentScript({
|
||||
matches: ["*://duckduckgo.com/*"],
|
||||
runAt: "document_end",
|
||||
main: () => {},
|
||||
})`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/four.content/style.css',
|
||||
`body { color: yellow }`,
|
||||
);
|
||||
|
||||
await project.build();
|
||||
|
||||
const manifest = await project.getOutputManifest();
|
||||
|
||||
expect(manifest.content_scripts).toContainEqual({
|
||||
matches: ['*://duckduckgo.com/*'],
|
||||
run_at: 'document_end',
|
||||
css: ['content-scripts/four.css'],
|
||||
js: ['content-scripts/four.js'],
|
||||
});
|
||||
expect(manifest.content_scripts).toContainEqual({
|
||||
matches: ['*://google.com/*'],
|
||||
run_at: 'document_end',
|
||||
css: ['content-scripts/three.css', 'content-scripts/two.css'],
|
||||
js: ['content-scripts/three.js', 'content-scripts/two.js'],
|
||||
});
|
||||
expect(manifest.content_scripts).toContainEqual({
|
||||
matches: ['*://google.com/*'],
|
||||
css: ['content-scripts/one.css'],
|
||||
js: ['content-scripts/one.js'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should add to any content scripts declared in wxt.config.ts', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/one.content/index.ts',
|
||||
`export default defineContentScript({
|
||||
matches: ["*://google.com/*"],
|
||||
main: () => {},
|
||||
})`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/two.content/style.css',
|
||||
`body {
|
||||
background-color: red;
|
||||
}`,
|
||||
);
|
||||
project.setConfigFileConfig({
|
||||
manifest: {
|
||||
content_scripts: [
|
||||
{
|
||||
css: ['content-scripts/two.css'],
|
||||
matches: ['*://*.google.com/*'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await project.build();
|
||||
|
||||
const manifest = await project.getOutputManifest();
|
||||
|
||||
expect(manifest.content_scripts).toContainEqual({
|
||||
css: ['content-scripts/two.css'],
|
||||
matches: ['*://*.google.com/*'],
|
||||
});
|
||||
expect(manifest.content_scripts).toContainEqual({
|
||||
matches: ['*://google.com/*'],
|
||||
js: ['content-scripts/one.js'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should add a CSS entry when cssInjectionMode is undefined', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/content/style.css',
|
||||
'body { background-color: red; }',
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/content/index.ts',
|
||||
`import "./style.css";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["https://*.google.com/*"],
|
||||
|
||||
main() {},
|
||||
});`,
|
||||
);
|
||||
await project.build();
|
||||
|
||||
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"css\\":[\\"content-scripts/content.css\\"],\\"js\\":[\\"content-scripts/content.js\\"]}]}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should add a CSS entry when cssInjectionMode is "manifest"', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/content/style.css',
|
||||
'body { background-color: red; }',
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/content/index.ts',
|
||||
`import "./style.css";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["https://*.google.com/*"],
|
||||
cssInjectionMode: "manifest",
|
||||
|
||||
main() {},
|
||||
});`,
|
||||
);
|
||||
await project.build();
|
||||
|
||||
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"css\\":[\\"content-scripts/content.css\\"],\\"js\\":[\\"content-scripts/content.js\\"]}]}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should not add an entry for CSS when cssInjectionMode is "manual"', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/content/style.css',
|
||||
'body { background-color: red; }',
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/content/index.ts',
|
||||
`import "./style.css";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["https://*.google.com/*"],
|
||||
cssInjectionMode: "manual",
|
||||
|
||||
main() {},
|
||||
});`,
|
||||
);
|
||||
await project.build();
|
||||
|
||||
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]}]}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should not add a content script entry for CSS when cssInjectionMode is "ui", but add a web_accessible_resources entry for MV2', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/content/style.css',
|
||||
'body { background-color: red; }',
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/content/index.ts',
|
||||
`import "./style.css";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["https://*.google.com/*"],
|
||||
cssInjectionMode: "ui",
|
||||
|
||||
main() {},
|
||||
});`,
|
||||
);
|
||||
await project.build({
|
||||
manifestVersion: 2,
|
||||
});
|
||||
|
||||
expect(await project.serializeFile('.output/chrome-mv2/manifest.json'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv2/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":2,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]}],\\"web_accessible_resources\\":[\\"content-scripts/content.css\\"]}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should not add a content script entry for CSS when cssInjectionMode is "ui", but add a web_accessible_resources entry for MV3', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/content/style.css',
|
||||
'body { background-color: red; }',
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/content/index.ts',
|
||||
`import "./style.css";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["https://*.google.com/*"],
|
||||
cssInjectionMode: "ui",
|
||||
|
||||
main() {},
|
||||
});`,
|
||||
);
|
||||
await project.build({
|
||||
manifestVersion: 3,
|
||||
});
|
||||
|
||||
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]}],\\"web_accessible_resources\\":[{\\"resources\\":[\\"content-scripts/content.css\\"],\\"matches\\":[\\"https://*.google.com/*\\"]}]}"
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should combine web accessible resources', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/content/style.css',
|
||||
'body { background-color: red; }',
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/content/index.ts',
|
||||
`import "./style.css";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["https://*.google.com/*"],
|
||||
cssInjectionMode: "ui",
|
||||
|
||||
main() {},
|
||||
});`,
|
||||
);
|
||||
project.setConfigFileConfig({
|
||||
manifest: {
|
||||
web_accessible_resources: [
|
||||
{ resources: ['one.png'], matches: ['https://one.com/*'] },
|
||||
],
|
||||
},
|
||||
});
|
||||
await project.build();
|
||||
|
||||
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"web_accessible_resources\\":[{\\"resources\\":[\\"one.png\\"],\\"matches\\":[\\"https://one.com/*\\"]},{\\"resources\\":[\\"content-scripts/content.css\\"],\\"matches\\":[\\"https://*.google.com/*\\"]}],\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]}]}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should respect the transformManifest option', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'wxt.config.ts',
|
||||
`import { defineConfig } from 'wxt';
|
||||
|
||||
export default defineConfig({
|
||||
transformManifest(manifest) {
|
||||
manifest.author = "Custom Author"
|
||||
}
|
||||
})`,
|
||||
);
|
||||
|
||||
await project.build();
|
||||
|
||||
const output = await project.serializeFile(
|
||||
'.output/chrome-mv3/manifest.json',
|
||||
);
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"author\\":\\"Custom Author\\"}"
|
||||
`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ browser: undefined, outDir: 'chrome-mv3', expected: undefined },
|
||||
{ browser: 'chrome', outDir: 'chrome-mv3', expected: undefined },
|
||||
@@ -575,91 +30,4 @@ describe('Manifest Content', () => {
|
||||
expect(safariManifest.background.persistent).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
describe('versions', () => {
|
||||
it.each([
|
||||
['chrome', 3] as const,
|
||||
['safari', 2] as const,
|
||||
['edge', 3] as const,
|
||||
])(
|
||||
'should include version_name on %s when it needs simplified',
|
||||
async (browser, manifestVersion) => {
|
||||
const project = new TestProject({
|
||||
version: '1.0.0-alpha1',
|
||||
});
|
||||
|
||||
await project.build({ browser, manifestVersion });
|
||||
const manifest = await project.getOutputManifest(
|
||||
`.output/${browser}-mv${manifestVersion}/manifest.json`,
|
||||
);
|
||||
|
||||
expect(manifest.version).toBe('1.0.0');
|
||||
expect(manifest.version_name).toBe('1.0.0-alpha1');
|
||||
},
|
||||
);
|
||||
|
||||
it.each([['firefox', 2] as const])(
|
||||
"should not include a version_name on %s because the browser doesn't support it",
|
||||
async (browser, manifestVersion) => {
|
||||
const project = new TestProject({
|
||||
version: '1.0.0-alpha1',
|
||||
});
|
||||
|
||||
await project.build({ browser, manifestVersion });
|
||||
const manifest = await project.getOutputManifest(
|
||||
`.output/${browser}-mv${manifestVersion}/manifest.json`,
|
||||
);
|
||||
|
||||
expect(manifest.version).toBe('1.0.0');
|
||||
expect(manifest.version_name).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['chrome', 3] as const,
|
||||
['firefox', 2] as const,
|
||||
['safari', 3] as const,
|
||||
['edge', 3] as const,
|
||||
])(
|
||||
'should not include the version_name if it is equal to version',
|
||||
async (browser, manifestVersion) => {
|
||||
const project = new TestProject({
|
||||
version: '1.0.0.1',
|
||||
});
|
||||
|
||||
await project.build({ browser, manifestVersion });
|
||||
const manifest = await project.getOutputManifest(
|
||||
`.output/${browser}-mv${manifestVersion}/manifest.json`,
|
||||
);
|
||||
|
||||
expect(manifest.version).toBe('1.0.0.1');
|
||||
expect(manifest.version_name).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect the action field in the manifest without a popup', async () => {
|
||||
const project = new TestProject();
|
||||
project.setConfigFileConfig({
|
||||
manifest: {
|
||||
action: {
|
||||
default_title: 'Hello world',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await project.build();
|
||||
|
||||
expect(await project.getOutputManifest()).toMatchInlineSnapshot(`
|
||||
{
|
||||
"action": {
|
||||
"default_title": "Hello world",
|
||||
},
|
||||
"description": "Example description",
|
||||
"manifest_version": 3,
|
||||
"name": "E2E Extension",
|
||||
"version": "0.0.0",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,13 +7,18 @@ describe('Output Directory Structure', () => {
|
||||
project.addFile('entrypoints/.DS_Store');
|
||||
project.addFile('entrypoints/.hidden1/index.html');
|
||||
project.addFile('entrypoints/.hidden2.html');
|
||||
project.addFile('entrypoints/unlisted.html');
|
||||
|
||||
await project.build();
|
||||
|
||||
expect(await project.serializeOutput()).toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\"}"
|
||||
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0"}
|
||||
================================================================================
|
||||
.output/chrome-mv3/unlisted.html
|
||||
----------------------------------------
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -69,7 +74,7 @@ describe('Output Directory Structure', () => {
|
||||
================================================================================
|
||||
.output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"content_scripts\\":[{\\"matches\\":[\\"*://*/*\\"],\\"css\\":[\\"content-scripts/one.css\\",\\"content-scripts/two.css\\"],\\"js\\":[\\"content-scripts/one.js\\",\\"content-scripts/two.js\\"]}]}"
|
||||
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","content_scripts":[{"matches":["*://*/*"],"css":["content-scripts/one.css","content-scripts/two.css"],"js":["content-scripts/one.js","content-scripts/two.js"]}]}"
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -96,7 +101,7 @@ describe('Output Directory Structure', () => {
|
||||
================================================================================
|
||||
.output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"content_scripts\\":[{\\"matches\\":[\\"*://*/*\\"],\\"js\\":[\\"content-scripts/overlay-one.js\\"]}]}"
|
||||
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","content_scripts":[{"matches":["*://*/*"],"js":["content-scripts/overlay-one.js"]}]}"
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -197,7 +202,7 @@ describe('Output Directory Structure', () => {
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"background\\":{\\"service_worker\\":\\"background.js\\"},\\"content_scripts\\":[{\\"matches\\":[\\"*://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]},{\\"matches\\":[\\"*://*.duckduckgo.com/*\\"],\\"js\\":[\\"content-scripts/named.js\\"]}]}"
|
||||
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","background":{"service_worker":"background.js"},"content_scripts":[{"matches":["*://*.google.com/*"],"js":["content-scripts/content.js"]},{"matches":["*://*.duckduckgo.com/*"],"js":["content-scripts/named.js"]}]}"
|
||||
`);
|
||||
expect(await project.fileExists('.output/chrome-mv3/background.js'));
|
||||
expect(
|
||||
@@ -208,4 +213,183 @@ describe('Output Directory Structure', () => {
|
||||
);
|
||||
expect(await project.fileExists('.output/chrome-mv3/unlisted.js'));
|
||||
});
|
||||
|
||||
it("should output to a custom directory when overriding 'outDir'", async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/unlisted.html');
|
||||
project.setConfigFileConfig({
|
||||
outDir: 'dist',
|
||||
});
|
||||
|
||||
await project.build();
|
||||
|
||||
expect(await project.fileExists('dist/chrome-mv3/manifest.json')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate ESM background script when type=module', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'utils/log.ts',
|
||||
`export function logHello(name: string) {
|
||||
console.log(\`Hello \${name}!\`);
|
||||
}`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/background.ts',
|
||||
`export default defineBackground({
|
||||
type: "module",
|
||||
main() {
|
||||
logHello("background");
|
||||
},
|
||||
})`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/popup/index.html',
|
||||
`<html>
|
||||
<head>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</head>
|
||||
</html>`,
|
||||
);
|
||||
project.addFile('entrypoints/popup/main.ts', `logHello('popup')`);
|
||||
|
||||
await project.build({
|
||||
experimental: {
|
||||
// Simplify the build output for comparison
|
||||
includeBrowserPolyfill: false,
|
||||
},
|
||||
vite: () => ({
|
||||
build: {
|
||||
// Make output for snapshot readible
|
||||
minify: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(await project.serializeFile('.output/chrome-mv3/background.js'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/background.js
|
||||
----------------------------------------
|
||||
import { l as logHello } from "./chunks/log-bezs0tt4.js";
|
||||
function defineBackground(arg) {
|
||||
if (typeof arg === "function")
|
||||
return { main: arg };
|
||||
return arg;
|
||||
}
|
||||
const definition = defineBackground({
|
||||
type: "module",
|
||||
main() {
|
||||
logHello("background");
|
||||
}
|
||||
});
|
||||
chrome;
|
||||
function print(method, ...args) {
|
||||
return;
|
||||
}
|
||||
var logger = {
|
||||
debug: (...args) => print(console.debug, ...args),
|
||||
log: (...args) => print(console.log, ...args),
|
||||
warn: (...args) => print(console.warn, ...args),
|
||||
error: (...args) => print(console.error, ...args)
|
||||
};
|
||||
try {
|
||||
const res = definition.main();
|
||||
if (res instanceof Promise) {
|
||||
console.warn(
|
||||
"The background's main() function return a promise, but it must be synchonous"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("The background crashed on startup!");
|
||||
throw err;
|
||||
}
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should generate IIFE background script when type=undefined', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'utils/log.ts',
|
||||
`export function logHello(name: string) {
|
||||
console.log(\`Hello \${name}!\`);
|
||||
}`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/background.ts',
|
||||
`export default defineBackground({
|
||||
main() {
|
||||
logHello("background");
|
||||
},
|
||||
})`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/popup/index.html',
|
||||
`<html>
|
||||
<head>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</head>
|
||||
</html>`,
|
||||
);
|
||||
project.addFile('entrypoints/popup/main.ts', `logHello('popup')`);
|
||||
|
||||
await project.build({
|
||||
experimental: {
|
||||
// Simplify the build output for comparison
|
||||
includeBrowserPolyfill: false,
|
||||
},
|
||||
vite: () => ({
|
||||
build: {
|
||||
// Make output for snapshot readible
|
||||
minify: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(await project.serializeFile('.output/chrome-mv3/background.js'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/background.js
|
||||
----------------------------------------
|
||||
(function() {
|
||||
"use strict";
|
||||
function defineBackground(arg) {
|
||||
if (typeof arg === "function")
|
||||
return { main: arg };
|
||||
return arg;
|
||||
}
|
||||
function logHello(name) {
|
||||
console.log(\`Hello \${name}!\`);
|
||||
}
|
||||
const definition = defineBackground({
|
||||
main() {
|
||||
logHello("background");
|
||||
}
|
||||
});
|
||||
chrome;
|
||||
function print(method, ...args) {
|
||||
return;
|
||||
}
|
||||
var logger = {
|
||||
debug: (...args) => print(console.debug, ...args),
|
||||
log: (...args) => print(console.log, ...args),
|
||||
warn: (...args) => print(console.warn, ...args),
|
||||
error: (...args) => print(console.error, ...args)
|
||||
};
|
||||
try {
|
||||
const res = definition.main();
|
||||
if (res instanceof Promise) {
|
||||
console.warn(
|
||||
"The background's main() function return a promise, but it must be synchonous"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("The background crashed on startup!");
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('React', () => {
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"content_scripts\\":[{\\"matches\\":\\"<all_urls>\\",\\"js\\":[\\"content-scripts/demo.js\\"]}]}"
|
||||
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","content_scripts":[{"matches":"<all_urls>","js":["content-scripts/demo.js"]}]}"
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TestProject } from '../utils';
|
||||
describe('TypeScript Project', () => {
|
||||
it('should generate defined constants correctly', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/unlisted.html');
|
||||
|
||||
await project.build();
|
||||
|
||||
@@ -13,16 +14,19 @@ describe('TypeScript Project', () => {
|
||||
----------------------------------------
|
||||
// Generated by wxt
|
||||
export {}
|
||||
declare global {
|
||||
const __MANIFEST_VERSION__: 2 | 3;
|
||||
const __BROWSER__: string;
|
||||
const __IS_CHROME__: boolean;
|
||||
const __IS_FIREFOX__: boolean;
|
||||
const __IS_SAFARI__: boolean;
|
||||
const __IS_EDGE__: boolean;
|
||||
const __IS_OPERA__: boolean;
|
||||
const __COMMAND__: \\"build\\" | \\"serve\\";
|
||||
const __ENTRYPOINT__: string;
|
||||
interface ImportMetaEnv {
|
||||
readonly MANIFEST_VERSION: 2 | 3;
|
||||
readonly BROWSER: string;
|
||||
readonly CHROME: boolean;
|
||||
readonly FIREFOX: boolean;
|
||||
readonly SAFARI: boolean;
|
||||
readonly EDGE: boolean;
|
||||
readonly OPERA: boolean;
|
||||
readonly COMMAND: "build" | "serve";
|
||||
readonly ENTRYPOINT: string;
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
"
|
||||
`);
|
||||
@@ -41,15 +45,17 @@ describe('TypeScript Project', () => {
|
||||
".wxt/types/paths.d.ts
|
||||
----------------------------------------
|
||||
// Generated by wxt
|
||||
import \\"wxt/browser\\";
|
||||
import "wxt/browser";
|
||||
|
||||
declare module \\"wxt/browser\\" {
|
||||
declare module "wxt/browser" {
|
||||
export type PublicPath =
|
||||
| \\"/options.html\\"
|
||||
| \\"/popup.html\\"
|
||||
| \\"/sandbox.html\\"
|
||||
| "/options.html"
|
||||
| "/popup.html"
|
||||
| "/sandbox.html"
|
||||
type HtmlPublicPath = Extract<PublicPath, \`\${string}.html\`>
|
||||
export interface WxtRuntime extends Runtime.Static {
|
||||
getURL(path: PublicPath): string;
|
||||
getURL(path: \`\${HtmlPublicPath}\${string}\`): string;
|
||||
}
|
||||
}
|
||||
"
|
||||
@@ -58,6 +64,7 @@ describe('TypeScript Project', () => {
|
||||
|
||||
it('should augment the types for browser.i18n.getMessage', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/unlisted.html');
|
||||
project.addFile(
|
||||
'public/_locales/en/messages.json',
|
||||
JSON.stringify({
|
||||
@@ -103,9 +110,9 @@ describe('TypeScript Project', () => {
|
||||
".wxt/types/i18n.d.ts
|
||||
----------------------------------------
|
||||
// Generated by wxt
|
||||
import \\"wxt/browser\\";
|
||||
import "wxt/browser";
|
||||
|
||||
declare module \\"wxt/browser\\" {
|
||||
declare module "wxt/browser" {
|
||||
/**
|
||||
* See https://developer.chrome.com/docs/extensions/reference/i18n/#method-getMessage
|
||||
*/
|
||||
@@ -121,90 +128,90 @@ describe('TypeScript Project', () => {
|
||||
* The extension or app ID; you might use this string to construct URLs for resources inside the extension. Even unlocalized extensions can use this message.
|
||||
Note: You can't use this message in a manifest file.
|
||||
*
|
||||
* \\"<browser.runtime.id>\\"
|
||||
* "<browser.runtime.id>"
|
||||
*/
|
||||
getMessage(
|
||||
messageName: \\"@@extension_id\\",
|
||||
messageName: "@@extension_id",
|
||||
substitutions?: string | string[],
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
/**
|
||||
*
|
||||
* No message description.
|
||||
*
|
||||
* \\"<browser.i18n.getUiLocale()>\\"
|
||||
* "<browser.i18n.getUiLocale()>"
|
||||
*/
|
||||
getMessage(
|
||||
messageName: \\"@@ui_locale\\",
|
||||
messageName: "@@ui_locale",
|
||||
substitutions?: string | string[],
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
/**
|
||||
* The text direction for the current locale, either \\"ltr\\" for left-to-right languages such as English or \\"rtl\\" for right-to-left languages such as Japanese.
|
||||
* The text direction for the current locale, either "ltr" for left-to-right languages such as English or "rtl" for right-to-left languages such as Japanese.
|
||||
*
|
||||
* \\"<ltr|rtl>\\"
|
||||
* "<ltr|rtl>"
|
||||
*/
|
||||
getMessage(
|
||||
messageName: \\"@@bidi_dir\\",
|
||||
messageName: "@@bidi_dir",
|
||||
substitutions?: string | string[],
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
/**
|
||||
* If the @@bidi_dir is \\"ltr\\", then this is \\"rtl\\"; otherwise, it's \\"ltr\\".
|
||||
* If the @@bidi_dir is "ltr", then this is "rtl"; otherwise, it's "ltr".
|
||||
*
|
||||
* \\"<rtl|ltr>\\"
|
||||
* "<rtl|ltr>"
|
||||
*/
|
||||
getMessage(
|
||||
messageName: \\"@@bidi_reversed_dir\\",
|
||||
messageName: "@@bidi_reversed_dir",
|
||||
substitutions?: string | string[],
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
/**
|
||||
* If the @@bidi_dir is \\"ltr\\", then this is \\"left\\"; otherwise, it's \\"right\\".
|
||||
* If the @@bidi_dir is "ltr", then this is "left"; otherwise, it's "right".
|
||||
*
|
||||
* \\"<left|right>\\"
|
||||
* "<left|right>"
|
||||
*/
|
||||
getMessage(
|
||||
messageName: \\"@@bidi_start_edge\\",
|
||||
messageName: "@@bidi_start_edge",
|
||||
substitutions?: string | string[],
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
/**
|
||||
* If the @@bidi_dir is \\"ltr\\", then this is \\"right\\"; otherwise, it's \\"left\\".
|
||||
* If the @@bidi_dir is "ltr", then this is "right"; otherwise, it's "left".
|
||||
*
|
||||
* \\"<right|left>\\"
|
||||
* "<right|left>"
|
||||
*/
|
||||
getMessage(
|
||||
messageName: \\"@@bidi_end_edge\\",
|
||||
messageName: "@@bidi_end_edge",
|
||||
substitutions?: string | string[],
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
/**
|
||||
* Ask for the user's name
|
||||
*
|
||||
* \\"What's your name?\\"
|
||||
* "What's your name?"
|
||||
*/
|
||||
getMessage(
|
||||
messageName: \\"prompt_for_name\\",
|
||||
messageName: "prompt_for_name",
|
||||
substitutions?: string | string[],
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
/**
|
||||
* Greet the user
|
||||
*
|
||||
* \\"Hello, $USER$\\"
|
||||
* "Hello, $USER$"
|
||||
*/
|
||||
getMessage(
|
||||
messageName: \\"hello\\",
|
||||
messageName: "hello",
|
||||
substitutions?: string | string[],
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
/**
|
||||
* Say goodbye to the user
|
||||
*
|
||||
* \\"Goodbye, $USER$. Come back to $OUR_SITE$ soon!\\"
|
||||
* "Goodbye, $USER$. Come back to $OUR_SITE$ soon!"
|
||||
*/
|
||||
getMessage(
|
||||
messageName: \\"bye\\",
|
||||
messageName: "bye",
|
||||
substitutions?: string | string[],
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
@@ -216,6 +223,7 @@ describe('TypeScript Project', () => {
|
||||
|
||||
it('should reference all the required types in a single declaration file', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/unlisted.html');
|
||||
|
||||
await project.build();
|
||||
|
||||
@@ -224,17 +232,18 @@ describe('TypeScript Project', () => {
|
||||
".wxt/wxt.d.ts
|
||||
----------------------------------------
|
||||
// Generated by wxt
|
||||
/// <reference types=\\"vite/client\\" />
|
||||
/// <reference types=\\"./types/imports.d.ts\\" />
|
||||
/// <reference types=\\"./types/paths.d.ts\\" />
|
||||
/// <reference types=\\"./types/i18n.d.ts\\" />
|
||||
/// <reference types=\\"./types/globals.d.ts\\" />
|
||||
/// <reference types="wxt/vite-builder-env" />
|
||||
/// <reference types="./types/imports.d.ts" />
|
||||
/// <reference types="./types/paths.d.ts" />
|
||||
/// <reference types="./types/i18n.d.ts" />
|
||||
/// <reference types="./types/globals.d.ts" />
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should generate a TSConfig file for the project', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/unlisted.html');
|
||||
|
||||
await project.build();
|
||||
|
||||
@@ -243,38 +252,39 @@ describe('TypeScript Project', () => {
|
||||
".wxt/tsconfig.json
|
||||
----------------------------------------
|
||||
{
|
||||
\\"compilerOptions\\": {
|
||||
\\"target\\": \\"ESNext\\",
|
||||
\\"module\\": \\"ESNext\\",
|
||||
\\"moduleResolution\\": \\"Bundler\\",
|
||||
\\"noEmit\\": true,
|
||||
\\"esModuleInterop\\": true,
|
||||
\\"forceConsistentCasingInFileNames\\": true,
|
||||
\\"resolveJsonModule\\": true,
|
||||
\\"strict\\": true,
|
||||
\\"skipLibCheck\\": true,
|
||||
\\"paths\\": {
|
||||
\\"@\\": [\\"..\\"],
|
||||
\\"@/*\\": [\\"../*\\"],
|
||||
\\"~\\": [\\"..\\"],
|
||||
\\"~/*\\": [\\"../*\\"],
|
||||
\\"@@\\": [\\"..\\"],
|
||||
\\"@@/*\\": [\\"../*\\"],
|
||||
\\"~~\\": [\\"..\\"],
|
||||
\\"~~/*\\": [\\"../*\\"]
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@": [".."],
|
||||
"@/*": ["../*"],
|
||||
"~": [".."],
|
||||
"~/*": ["../*"],
|
||||
"@@": [".."],
|
||||
"@@/*": ["../*"],
|
||||
"~~": [".."],
|
||||
"~~/*": ["../*"]
|
||||
}
|
||||
},
|
||||
\\"include\\": [
|
||||
\\"../**/*\\",
|
||||
\\"./wxt.d.ts\\"
|
||||
"include": [
|
||||
"../**/*",
|
||||
"./wxt.d.ts"
|
||||
],
|
||||
\\"exclude\\": [\\"../.output\\"]
|
||||
"exclude": ["../.output"]
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should generate correct path aliases for a custom srcDir', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('src/entrypoints/unlisted.html');
|
||||
project.setConfigFileConfig({
|
||||
srcDir: 'src',
|
||||
});
|
||||
@@ -286,38 +296,39 @@ describe('TypeScript Project', () => {
|
||||
".wxt/tsconfig.json
|
||||
----------------------------------------
|
||||
{
|
||||
\\"compilerOptions\\": {
|
||||
\\"target\\": \\"ESNext\\",
|
||||
\\"module\\": \\"ESNext\\",
|
||||
\\"moduleResolution\\": \\"Bundler\\",
|
||||
\\"noEmit\\": true,
|
||||
\\"esModuleInterop\\": true,
|
||||
\\"forceConsistentCasingInFileNames\\": true,
|
||||
\\"resolveJsonModule\\": true,
|
||||
\\"strict\\": true,
|
||||
\\"skipLibCheck\\": true,
|
||||
\\"paths\\": {
|
||||
\\"@\\": [\\"../src\\"],
|
||||
\\"@/*\\": [\\"../src/*\\"],
|
||||
\\"~\\": [\\"../src\\"],
|
||||
\\"~/*\\": [\\"../src/*\\"],
|
||||
\\"@@\\": [\\"..\\"],
|
||||
\\"@@/*\\": [\\"../*\\"],
|
||||
\\"~~\\": [\\"..\\"],
|
||||
\\"~~/*\\": [\\"../*\\"]
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@": ["../src"],
|
||||
"@/*": ["../src/*"],
|
||||
"~": ["../src"],
|
||||
"~/*": ["../src/*"],
|
||||
"@@": [".."],
|
||||
"@@/*": ["../*"],
|
||||
"~~": [".."],
|
||||
"~~/*": ["../*"]
|
||||
}
|
||||
},
|
||||
\\"include\\": [
|
||||
\\"../**/*\\",
|
||||
\\"./wxt.d.ts\\"
|
||||
"include": [
|
||||
"../**/*",
|
||||
"./wxt.d.ts"
|
||||
],
|
||||
\\"exclude\\": [\\"../.output\\"]
|
||||
"exclude": ["../.output"]
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should add additional path aliases listed in the alias config, preventing defaults from being overridden', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('src/entrypoints/unlisted.html');
|
||||
project.setConfigFileConfig({
|
||||
srcDir: 'src',
|
||||
alias: {
|
||||
@@ -333,34 +344,34 @@ describe('TypeScript Project', () => {
|
||||
".wxt/tsconfig.json
|
||||
----------------------------------------
|
||||
{
|
||||
\\"compilerOptions\\": {
|
||||
\\"target\\": \\"ESNext\\",
|
||||
\\"module\\": \\"ESNext\\",
|
||||
\\"moduleResolution\\": \\"Bundler\\",
|
||||
\\"noEmit\\": true,
|
||||
\\"esModuleInterop\\": true,
|
||||
\\"forceConsistentCasingInFileNames\\": true,
|
||||
\\"resolveJsonModule\\": true,
|
||||
\\"strict\\": true,
|
||||
\\"skipLibCheck\\": true,
|
||||
\\"paths\\": {
|
||||
\\"example\\": [\\"../example\\"],
|
||||
\\"example/*\\": [\\"../example/*\\"],
|
||||
\\"@\\": [\\"../src\\"],
|
||||
\\"@/*\\": [\\"../src/*\\"],
|
||||
\\"~\\": [\\"../src\\"],
|
||||
\\"~/*\\": [\\"../src/*\\"],
|
||||
\\"@@\\": [\\"..\\"],
|
||||
\\"@@/*\\": [\\"../*\\"],
|
||||
\\"~~\\": [\\"..\\"],
|
||||
\\"~~/*\\": [\\"../*\\"]
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"example": ["../example"],
|
||||
"example/*": ["../example/*"],
|
||||
"@": ["../src"],
|
||||
"@/*": ["../src/*"],
|
||||
"~": ["../src"],
|
||||
"~/*": ["../src/*"],
|
||||
"@@": [".."],
|
||||
"@@/*": ["../*"],
|
||||
"~~": [".."],
|
||||
"~~/*": ["../*"]
|
||||
}
|
||||
},
|
||||
\\"include\\": [
|
||||
\\"../**/*\\",
|
||||
\\"./wxt.d.ts\\"
|
||||
"include": [
|
||||
"../**/*",
|
||||
"./wxt.d.ts"
|
||||
],
|
||||
\\"exclude\\": [\\"../.output\\"]
|
||||
"exclude": ["../.output"]
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('User Config', () => {
|
||||
================================================================================
|
||||
.output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"background\\":{\\"service_worker\\":\\"background.js\\"}}"
|
||||
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","background":{"service_worker":"background.js"}}"
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -55,12 +55,13 @@ describe('User Config', () => {
|
||||
================================================================================
|
||||
.output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"background\\":{\\"service_worker\\":\\"background.js\\"}}"
|
||||
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","background":{"service_worker":"background.js"}}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should merge inline and user config based manifests', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/unlisted.html');
|
||||
project.addFile(
|
||||
'wxt.config.ts',
|
||||
`import { defineConfig } from 'wxt';
|
||||
@@ -79,11 +80,11 @@ describe('User Config', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const output = await project.serializeOutput();
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"example_customization\\":[\\"production\\",\\"chrome\\",\\"3\\",\\"build\\"]}"
|
||||
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","example_customization":["3","build","production","chrome"]}"
|
||||
`);
|
||||
});
|
||||
|
||||
|
||||
+33
-6
@@ -2,7 +2,14 @@ import { dirname, join, relative, resolve } from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import glob from 'fast-glob';
|
||||
import { execaCommand } from 'execa';
|
||||
import { InlineConfig, UserConfig, build } from '../src';
|
||||
import {
|
||||
InlineConfig,
|
||||
UserConfig,
|
||||
build,
|
||||
createServer,
|
||||
prepare,
|
||||
zip,
|
||||
} from '../src';
|
||||
import { normalizePath } from '../src/core/utils/paths';
|
||||
import merge from 'lodash.merge';
|
||||
|
||||
@@ -63,10 +70,29 @@ export class TestProject {
|
||||
if (filename === 'wxt.config.ts') this.config = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the files to the test directory install dependencies, and build the project.
|
||||
*/
|
||||
async prepare(config: InlineConfig = {}) {
|
||||
await this.writeProjectToDisk();
|
||||
await prepare({ ...config, root: this.root });
|
||||
}
|
||||
|
||||
async build(config: InlineConfig = {}) {
|
||||
await this.writeProjectToDisk();
|
||||
await build({ ...config, root: this.root });
|
||||
}
|
||||
|
||||
async zip(config: InlineConfig = {}) {
|
||||
await this.writeProjectToDisk();
|
||||
await zip({ ...config, root: this.root });
|
||||
}
|
||||
|
||||
async startServer(config: InlineConfig = {}) {
|
||||
await this.writeProjectToDisk();
|
||||
const server = await createServer({ ...config, root: this.root });
|
||||
await server.start();
|
||||
return server;
|
||||
}
|
||||
|
||||
private async writeProjectToDisk() {
|
||||
if (this.config == null) this.setConfigFileConfig();
|
||||
|
||||
for (const file of this.files) {
|
||||
@@ -77,8 +103,9 @@ export class TestProject {
|
||||
await fs.writeFile(filePath, content ?? '', 'utf-8');
|
||||
}
|
||||
|
||||
await execaCommand('npm i --ignore-scripts', { cwd: this.root });
|
||||
await build({ ...config, root: this.root });
|
||||
await execaCommand('pnpm --ignore-workspace i --ignore-scripts', {
|
||||
cwd: this.root,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+76
-44
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wxt",
|
||||
"type": "module",
|
||||
"version": "0.10.2",
|
||||
"version": "0.16.6",
|
||||
"description": "Next gen framework for developing web extensions",
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
@@ -30,47 +30,70 @@
|
||||
"bin",
|
||||
"dist"
|
||||
],
|
||||
"bin": "./bin/wxt.cjs",
|
||||
"bin": "./bin/wxt.mjs",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/index.cjs",
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"./client": {
|
||||
"import": "./dist/client.js",
|
||||
"types": "./dist/client.d.ts"
|
||||
"types": "./dist/client.d.ts",
|
||||
"import": "./dist/client.js"
|
||||
},
|
||||
"./sandbox": {
|
||||
"import": "./dist/sandbox.js",
|
||||
"types": "./dist/sandbox.d.ts"
|
||||
"types": "./dist/sandbox.d.ts",
|
||||
"import": "./dist/sandbox.js"
|
||||
},
|
||||
"./browser": {
|
||||
"import": "./dist/browser.js",
|
||||
"types": "./dist/browser.d.ts"
|
||||
"types": "./dist/browser.d.ts",
|
||||
"import": "./dist/browser.js"
|
||||
},
|
||||
"./testing": {
|
||||
"require": "./dist/testing.cjs",
|
||||
"import": "./dist/testing.js",
|
||||
"types": "./dist/testing.d.ts"
|
||||
"import": {
|
||||
"types": "./dist/testing.d.ts",
|
||||
"default": "./dist/testing.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/testing.d.cts",
|
||||
"default": "./dist/testing.cjs"
|
||||
}
|
||||
},
|
||||
"./storage": {
|
||||
"require": "./dist/storage.cjs",
|
||||
"import": "./dist/storage.js",
|
||||
"types": "./dist/storage.d.ts"
|
||||
"import": {
|
||||
"types": "./dist/storage.d.ts",
|
||||
"default": "./dist/storage.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/storage.d.cts",
|
||||
"default": "./dist/storage.cjs"
|
||||
}
|
||||
},
|
||||
"./vite-builder-env": {
|
||||
"types": "./dist/vite-builder-env.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"wxt": "tsx src/cli.ts",
|
||||
"wxt": "tsx src/cli/index.ts",
|
||||
"build": "tsx scripts/build.ts",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --write .",
|
||||
"compile": "tsc --noEmit",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "run-p -c -s lint:*",
|
||||
"lint:eslint": "echo 'ESLint: TODO'",
|
||||
"lint:package": "publint",
|
||||
"compile": "run-s -c compile:*",
|
||||
"compile:wxt": "tsc --noEmit",
|
||||
"compile:virtual": "tsc --noEmit -p src/virtual",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage": "vitest run --coverage.enabled \"--coverage.include=src/**\" \"--coverage.exclude=src/core/utils/testing/**\" \"--coverage.exclude=**/*.d.ts\"",
|
||||
"prepare": "simple-git-hooks",
|
||||
"prepublish": "pnpm -s build",
|
||||
"docs:gen": "typedoc --options docs/typedoc.json",
|
||||
@@ -82,60 +105,69 @@
|
||||
"dependencies": {
|
||||
"@types/webextension-polyfill": "^0.10.5",
|
||||
"@webext-core/fake-browser": "^1.3.1",
|
||||
"@webext-core/isolated-element": "^1.0.4",
|
||||
"@webext-core/match-patterns": "^1.0.2",
|
||||
"@webext-core/isolated-element": "^1.1.1",
|
||||
"@webext-core/match-patterns": "^1.0.3",
|
||||
"async-mutex": "^0.4.0",
|
||||
"c12": "^1.5.1",
|
||||
"cac": "^6.7.14",
|
||||
"chokidar": "^3.5.3",
|
||||
"consola": "^3.2.3",
|
||||
"defu": "^6.1.3",
|
||||
"dequal": "^2.0.3",
|
||||
"esbuild": "^0.19.5",
|
||||
"fast-glob": "^3.3.1",
|
||||
"filesize": "^10.0.8",
|
||||
"fs-extra": "^11.1.1",
|
||||
"get-port": "^7.0.0",
|
||||
"giget": "^1.1.3",
|
||||
"immer": "^10.0.3",
|
||||
"hookable": "^5.5.3",
|
||||
"is-wsl": "^3.0.0",
|
||||
"jiti": "^1.21.0",
|
||||
"json5": "^2.2.3",
|
||||
"linkedom": "^0.16.1",
|
||||
"manage-path": "^2.0.0",
|
||||
"minimatch": "^9.0.3",
|
||||
"natural-compare": "^1.4.0",
|
||||
"normalize-path": "^3.0.0",
|
||||
"ora": "^7.0.1",
|
||||
"picocolors": "^1.0.0",
|
||||
"prompts": "^2.4.2",
|
||||
"publish-browser-extension": "^2.1.2",
|
||||
"rollup-plugin-visualizer": "^5.9.2",
|
||||
"unimport": "^3.4.0",
|
||||
"unstorage": "^1.9.0",
|
||||
"vite": "^4.0.0 || ^5.0.0-0",
|
||||
"web-ext-run": "^0.1.0",
|
||||
"vite": "^5.0.12",
|
||||
"web-ext-run": "^0.2.0",
|
||||
"webextension-polyfill": "^0.10.0",
|
||||
"zip-dir": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.2.0",
|
||||
"@types/fs-extra": "^11.0.3",
|
||||
"@types/lodash.merge": "^4.6.8",
|
||||
"@types/node": "^20.8.10",
|
||||
"@types/prompts": "^2.4.7",
|
||||
"@vitest/coverage-v8": "^0.34.6",
|
||||
"@faker-js/faker": "^8.3.1",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/lodash.merge": "^4.6.9",
|
||||
"@types/natural-compare": "^1.4.3",
|
||||
"@types/node": "^20.10.3",
|
||||
"@types/normalize-path": "^3.0.2",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@vitest/coverage-v8": "^1.0.1",
|
||||
"execa": "^8.0.1",
|
||||
"happy-dom": "^12.4.0",
|
||||
"lint-staged": "^15.0.2",
|
||||
"happy-dom": "^13.3.8",
|
||||
"lint-staged": "^15.2.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"p-map": "^6.0.0",
|
||||
"prettier": "^3.0.1",
|
||||
"p-map": "^7.0.0",
|
||||
"prettier": "^3.1.0",
|
||||
"publint": "^0.2.6",
|
||||
"simple-git-hooks": "^2.9.0",
|
||||
"tsup": "^7.2.0",
|
||||
"tsx": "^3.12.7",
|
||||
"typedoc": "^0.25.3",
|
||||
"tsup": "^8.0.1",
|
||||
"tsx": "^4.6.2",
|
||||
"typedoc": "^0.25.4",
|
||||
"typedoc-plugin-markdown": "4.0.0-next.23",
|
||||
"typedoc-vitepress-theme": "1.0.0-next.3",
|
||||
"typescript": "^5.2.2",
|
||||
"vitepress": "1.0.0-rc.24",
|
||||
"vitest": "^0.34.6",
|
||||
"typescript": "^5.3.2",
|
||||
"vitepress": "1.0.0-rc.34",
|
||||
"vitest": "^1.1.0",
|
||||
"vitest-mock-extended": "^1.3.1",
|
||||
"vue": "^3.3.7"
|
||||
"vue": "^3.3.10"
|
||||
},
|
||||
"packageManager": "pnpm@8.6.3",
|
||||
"simple-git-hooks": {
|
||||
|
||||
Generated
+1250
-980
File diff suppressed because it is too large
Load Diff
+22
-11
@@ -16,22 +16,22 @@ const startTime = Date.now();
|
||||
const outDir = 'dist';
|
||||
await fs.rm(path.join(outDir, '*'), { recursive: true, force: true });
|
||||
|
||||
const preset: tsup.Options = {
|
||||
const preset = {
|
||||
dts: true,
|
||||
silent: true,
|
||||
sourcemap: false,
|
||||
external: [
|
||||
'vite',
|
||||
'virtual:user-unlisted-script',
|
||||
'virtual:user-content-script',
|
||||
'virtual:user-content-script-isolated-world',
|
||||
'virtual:user-content-script-main-world',
|
||||
'virtual:user-background',
|
||||
],
|
||||
};
|
||||
} satisfies tsup.Options;
|
||||
|
||||
function spinnerPMap(configs: tsup.Options[]) {
|
||||
let completed = 0;
|
||||
let progress = 1;
|
||||
const updateSpinner = () => {
|
||||
spinner.text = `${spinnerText} [${completed}/${configs.length}]`;
|
||||
spinner.text = `${spinnerText} [${progress}/${configs.length}]`;
|
||||
};
|
||||
updateSpinner();
|
||||
|
||||
@@ -39,7 +39,7 @@ function spinnerPMap(configs: tsup.Options[]) {
|
||||
config,
|
||||
async (config) => {
|
||||
const res = await tsup.build(config);
|
||||
completed++;
|
||||
progress++;
|
||||
updateSpinner();
|
||||
return res;
|
||||
},
|
||||
@@ -76,8 +76,10 @@ const config: tsup.Options[] = [
|
||||
...preset,
|
||||
entry: {
|
||||
'virtual/background-entrypoint': 'src/virtual/background-entrypoint.ts',
|
||||
'virtual/content-script-entrypoint':
|
||||
'src/virtual/content-script-entrypoint.ts',
|
||||
'virtual/content-script-isolated-world-entrypoint':
|
||||
'src/virtual/content-script-isolated-world-entrypoint.ts',
|
||||
'virtual/content-script-main-world-entrypoint':
|
||||
'src/virtual/content-script-main-world-entrypoint.ts',
|
||||
'virtual/mock-browser': 'src/virtual/mock-browser.ts',
|
||||
'virtual/reload-html': 'src/virtual/reload-html.ts',
|
||||
'virtual/unlisted-script-entrypoint':
|
||||
@@ -86,14 +88,20 @@ const config: tsup.Options[] = [
|
||||
format: ['esm'],
|
||||
splitting: false,
|
||||
dts: false,
|
||||
external: [...preset.external, 'wxt'],
|
||||
},
|
||||
// CJS-only
|
||||
{
|
||||
...preset,
|
||||
entry: {
|
||||
cli: 'src/cli.ts',
|
||||
cli: 'src/cli/index.ts',
|
||||
},
|
||||
format: ['esm'],
|
||||
banner: {
|
||||
// Fixes dynamic require of nodejs modules. See https://github.com/wxt-dev/wxt/issues/355
|
||||
// https://github.com/evanw/esbuild/issues/1921#issuecomment-1152991694
|
||||
js: "import { createRequire } from 'module';const require = createRequire(import.meta.url);",
|
||||
},
|
||||
format: ['cjs'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -103,6 +111,9 @@ await spinnerPMap(config).catch((err) => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Copy "public" files that need shipped inside WXT
|
||||
await fs.copyFile('src/vite-builder-env.d.ts', 'dist/vite-builder-env.d.ts');
|
||||
|
||||
spinner.clear().stop();
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { fakeBrowser } from '@webext-core/fake-browser';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { browser } from '~/browser';
|
||||
import { storage } from '~/storage';
|
||||
|
||||
describe('Storage Utils', () => {
|
||||
beforeEach(() => {
|
||||
fakeBrowser.reset();
|
||||
storage.unwatch();
|
||||
});
|
||||
|
||||
describe.each(['local', 'sync', 'managed', 'session'] as const)(
|
||||
@@ -15,33 +16,89 @@ describe('Storage Utils', () => {
|
||||
it('should return the value from the correct storage area', async () => {
|
||||
const expected = 123;
|
||||
await fakeBrowser.storage[storageArea].set({ count: expected });
|
||||
expect(await storage.getItem(`${storageArea}:count`)).toBe(expected);
|
||||
|
||||
const actual = await storage.getItem(`${storageArea}:count`);
|
||||
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('should return the value if multiple : are use in the key', async () => {
|
||||
const expected = 'value';
|
||||
await fakeBrowser.storage[storageArea].set({ 'some:key': expected });
|
||||
|
||||
const actual = await storage.getItem(`${storageArea}:some:key`);
|
||||
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it("should return null if the value doesn't exist", async () => {
|
||||
expect(await storage.getItem(`${storageArea}:count`)).toBeNull();
|
||||
const actual = await storage.getItem(`${storageArea}:count`);
|
||||
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the default value if passed in options', async () => {
|
||||
const expected = 0;
|
||||
const actual = await storage.getItem(`${storageArea}:count`, {
|
||||
defaultValue: expected,
|
||||
});
|
||||
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getItems', () => {
|
||||
it('should return an array of values in the same order as the keys passed in', async () => {
|
||||
it('should return an array of values', async () => {
|
||||
const expected = [
|
||||
{ key: `${storageArea}:count`, value: 234 },
|
||||
{ key: `${storageArea}:installDate`, value: null },
|
||||
{ key: `${storageArea}:otherValue`, value: 345 },
|
||||
];
|
||||
const params = [
|
||||
expected[0].key,
|
||||
expected[1].key,
|
||||
{
|
||||
key: expected[2].key,
|
||||
options: { defaultValue: expected[2].value },
|
||||
},
|
||||
];
|
||||
const keys = expected.map((item) => item.key);
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
count: expected[0].value,
|
||||
});
|
||||
|
||||
const actual = await storage.getItems(keys);
|
||||
const actual = await storage.getItems(params);
|
||||
|
||||
expect(actual).toHaveLength(3);
|
||||
expected.forEach((item) => {
|
||||
expect(actual).toContainEqual(item);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMeta', () => {
|
||||
it('should return item metadata from key+$', async () => {
|
||||
const expected = { v: 1 };
|
||||
await fakeBrowser.storage[storageArea].set({ count$: expected });
|
||||
|
||||
const actual = await storage.getMeta(`${storageArea}:count`);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should return an empty object if missing', async () => {
|
||||
const actual = await storage.getMeta(`${storageArea}:count`);
|
||||
|
||||
expect(actual).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setItem', () => {
|
||||
it('should set the value in the correct storage area', () => {});
|
||||
it('should set the value in the correct storage area', async () => {
|
||||
const key = `${storageArea}:count`;
|
||||
const value = 321;
|
||||
|
||||
await storage.setItem(key, value);
|
||||
});
|
||||
|
||||
it.each([undefined, null])(
|
||||
'should remove the item from storage when setting the value to %s',
|
||||
@@ -57,12 +114,685 @@ describe('Storage Utils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('setItems', () => {
|
||||
it('should set multiple items in storage', async () => {
|
||||
const expected = [
|
||||
{ key: `${storageArea}:count`, value: 234 },
|
||||
{ key: `${storageArea}:installDate`, value: null },
|
||||
];
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
count: 123,
|
||||
installDate: 321,
|
||||
});
|
||||
|
||||
await storage.setItems(expected);
|
||||
const actual = await storage.getItems(
|
||||
expected.map((item) => item.key),
|
||||
);
|
||||
|
||||
expect(actual).toHaveLength(2);
|
||||
expected.forEach((item) => {
|
||||
expect(actual).toContainEqual(item);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMeta', () => {
|
||||
it('should set metadata at key+$', async () => {
|
||||
const existing = { v: 1 };
|
||||
await browser.storage[storageArea].set({ count$: existing });
|
||||
const newValues = {
|
||||
date: Date.now(),
|
||||
};
|
||||
const expected = { ...existing, ...newValues };
|
||||
|
||||
await storage.setMeta(`${storageArea}:count`, newValues);
|
||||
const actual = await storage.getMeta(`${storageArea}:count`);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([undefined, null])(
|
||||
'should remove any properties set to %s',
|
||||
async (version) => {
|
||||
const existing = { v: 1 };
|
||||
await browser.storage[storageArea].set({ count$: existing });
|
||||
const expected = {};
|
||||
|
||||
await storage.setMeta(`${storageArea}:count`, { v: version });
|
||||
const actual = await storage.getMeta(`${storageArea}:count`);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('removeItem', () => {
|
||||
it('should remove the key from storage', async () => {
|
||||
await fakeBrowser.storage[storageArea].set({ count: 456 });
|
||||
await storage.removeItem(`${storageArea}:count`);
|
||||
|
||||
expect(await browser.storage[storageArea].get()).toEqual({});
|
||||
await storage.removeItem(`${storageArea}:count`);
|
||||
const actual = await storage.getItem(`${storageArea}:count`);
|
||||
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
|
||||
it('should not remove the metadata by default', async () => {
|
||||
const expected = { v: 1 };
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
count$: expected,
|
||||
count: 3,
|
||||
});
|
||||
|
||||
await storage.removeItem(`${storageArea}:count`);
|
||||
const actual = await storage.getMeta(`${storageArea}:count`);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should remove the metadata when requested', async () => {
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
count$: { v: 1 },
|
||||
count: 3,
|
||||
});
|
||||
|
||||
await storage.removeItem(`${storageArea}:count`, {
|
||||
removeMeta: true,
|
||||
});
|
||||
const actual = await storage.getMeta(`${storageArea}:count`);
|
||||
|
||||
expect(actual).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeItems', () => {
|
||||
it('should remove multiple items', async () => {
|
||||
const key1 = `${storageArea}:one`;
|
||||
const key2 = `${storageArea}:two`;
|
||||
const key3 = `${storageArea}:three`;
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
['one']: '1',
|
||||
['two']: null,
|
||||
['two$']: { v: 1 },
|
||||
['three']: '1',
|
||||
['three$']: { v: 1 },
|
||||
});
|
||||
|
||||
await storage.removeItems([
|
||||
key1,
|
||||
key2,
|
||||
{ key: key3, options: { removeMeta: true } },
|
||||
]);
|
||||
|
||||
expect(await storage.getItem(key1)).toBeNull();
|
||||
expect(await storage.getItem(key2)).toBeNull();
|
||||
expect(await storage.getMeta(key2)).toEqual({ v: 1 });
|
||||
expect(await storage.getItem(key3)).toBeNull();
|
||||
expect(await storage.getMeta(key3)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMeta', () => {
|
||||
it('should remove all metadata', async () => {
|
||||
await fakeBrowser.storage[storageArea].set({ count$: { v: 4 } });
|
||||
|
||||
await storage.removeMeta(`${storageArea}:count`);
|
||||
const actual = await storage.getMeta(`${storageArea}:count`);
|
||||
|
||||
expect(actual).toEqual({});
|
||||
});
|
||||
|
||||
it('should only remove specific properties', async () => {
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
count$: { v: 4, d: Date.now() },
|
||||
});
|
||||
|
||||
await storage.removeMeta(`${storageArea}:count`, ['d']);
|
||||
const actual = await storage.getMeta(`${storageArea}:count`);
|
||||
|
||||
expect(actual).toEqual({ v: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapshot', () => {
|
||||
it('should return a snapshot of the entire storage without area prefixes', async () => {
|
||||
const expected = {
|
||||
count: 1,
|
||||
count$: { v: 2 },
|
||||
example: 'test',
|
||||
};
|
||||
|
||||
await fakeBrowser.storage[storageArea].set(expected);
|
||||
const actual = await storage.snapshot(storageArea);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should exclude specific properties and their metadata', async () => {
|
||||
const input = {
|
||||
count: 1,
|
||||
count$: { v: 2 },
|
||||
example: 'test',
|
||||
};
|
||||
const excludeKeys = ['count'];
|
||||
const expected = {
|
||||
example: 'test',
|
||||
};
|
||||
|
||||
await fakeBrowser.storage[storageArea].set(input);
|
||||
const actual = await storage.snapshot(storageArea, { excludeKeys });
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreSnapshot', () => {
|
||||
it('should restore a snapshot object by setting all values in storage', async () => {
|
||||
const data = {
|
||||
one: 'one',
|
||||
two: 'two',
|
||||
};
|
||||
const existing = {
|
||||
two: 'two-two',
|
||||
three: 'three',
|
||||
};
|
||||
await fakeBrowser.storage[storageArea].set(existing);
|
||||
|
||||
await storage.restoreSnapshot(storageArea, data);
|
||||
const actual = await storage.snapshot(storageArea);
|
||||
|
||||
expect(actual).toEqual({ ...existing, ...data });
|
||||
});
|
||||
|
||||
it('should overwrite, not merge, any metadata keys in the snapshot', async () => {
|
||||
const existing = {
|
||||
count: 1,
|
||||
count$: {
|
||||
v: 2,
|
||||
},
|
||||
};
|
||||
const data = {
|
||||
count$: {
|
||||
restoredAt: Date.now(),
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
...existing,
|
||||
count$: data.count$,
|
||||
};
|
||||
await fakeBrowser.storage[storageArea].set(existing);
|
||||
|
||||
await storage.restoreSnapshot(storageArea, data);
|
||||
const actual = await storage.snapshot(storageArea);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('watch', () => {
|
||||
it('should not trigger if the changed key is different from the requested key', async () => {
|
||||
const cb = vi.fn();
|
||||
|
||||
storage.watch(`${storageArea}:key`, cb);
|
||||
await storage.setItem(`${storageArea}:not-the-key`, '123');
|
||||
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("should not trigger if the value doesn't change", async () => {
|
||||
const cb = vi.fn();
|
||||
const value = '123';
|
||||
|
||||
await storage.setItem(`${storageArea}:key`, value);
|
||||
storage.watch(`${storageArea}:key`, cb);
|
||||
await storage.setItem(`${storageArea}:key`, value);
|
||||
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should call the callback when the value changes', async () => {
|
||||
const cb = vi.fn();
|
||||
const newValue = '123';
|
||||
const oldValue = null;
|
||||
|
||||
storage.watch(`${storageArea}:key`, cb);
|
||||
await storage.setItem(`${storageArea}:key`, newValue);
|
||||
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
expect(cb).toBeCalledWith(newValue, oldValue);
|
||||
});
|
||||
|
||||
it('should remove the listener when calling the returned function', async () => {
|
||||
const cb = vi.fn();
|
||||
|
||||
const unwatch = storage.watch(`${storageArea}:key`, cb);
|
||||
unwatch();
|
||||
await storage.setItem(`${storageArea}:key`, '123');
|
||||
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unwatch', () => {
|
||||
it('should remove all watch listeners', async () => {
|
||||
const cb = vi.fn();
|
||||
|
||||
storage.watch(`${storageArea}:key`, cb);
|
||||
storage.unwatch();
|
||||
await storage.setItem(`${storageArea}:key`, '123');
|
||||
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('defineItem', () => {
|
||||
describe('versioning', () => {
|
||||
/**
|
||||
* This works because fakeBrowser is syncrounous, and is will finish any number of chained
|
||||
* calls within a single tick of the event loop, ie: a timeout of 0.
|
||||
*/
|
||||
async function waitForMigrations() {
|
||||
return new Promise((res) => setTimeout(res));
|
||||
}
|
||||
|
||||
it('should migrate values to the latest when a version upgrade is detected', async () => {
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
count: 2,
|
||||
count$: { v: 1 },
|
||||
});
|
||||
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
|
||||
const migrateToV3 = vi.fn((oldCount) => oldCount * 3);
|
||||
|
||||
const item = storage.defineItem<number, { v: number }>(
|
||||
`${storageArea}:count`,
|
||||
{
|
||||
version: 3,
|
||||
migrations: {
|
||||
2: migrateToV2,
|
||||
3: migrateToV3,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitForMigrations();
|
||||
|
||||
const actualValue = await item.getValue();
|
||||
const actualMeta = await item.getMeta();
|
||||
|
||||
expect(actualValue).toEqual(12);
|
||||
expect(actualMeta).toEqual({ v: 3 });
|
||||
|
||||
expect(migrateToV2).toBeCalledTimes(1);
|
||||
expect(migrateToV2).toBeCalledWith(2);
|
||||
|
||||
expect(migrateToV3).toBeCalledTimes(1);
|
||||
expect(migrateToV3).toBeCalledWith(4);
|
||||
});
|
||||
|
||||
it("should not run migrations if the value doesn't exist yet", async () => {
|
||||
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
|
||||
const migrateToV3 = vi.fn((oldCount) => oldCount * 3);
|
||||
|
||||
const item = storage.defineItem<number, { v: number }>(
|
||||
`${storageArea}:count`,
|
||||
{
|
||||
version: 3,
|
||||
migrations: {
|
||||
2: migrateToV2,
|
||||
3: migrateToV3,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitForMigrations();
|
||||
|
||||
const actualValue = await item.getValue();
|
||||
const actualMeta = await item.getMeta();
|
||||
|
||||
expect(actualValue).toBeNull();
|
||||
expect(actualMeta).toEqual({});
|
||||
|
||||
expect(migrateToV2).not.toBeCalled();
|
||||
expect(migrateToV3).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should run the v2 migration when converting an unversioned item to a versioned one', async () => {
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
count: 2,
|
||||
});
|
||||
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
|
||||
|
||||
const item = storage.defineItem<number, { v: number }>(
|
||||
`${storageArea}:count`,
|
||||
{
|
||||
version: 2,
|
||||
migrations: {
|
||||
2: migrateToV2,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitForMigrations();
|
||||
|
||||
const actualValue = await item.getValue();
|
||||
const actualMeta = await item.getMeta();
|
||||
|
||||
expect(actualValue).toEqual(4);
|
||||
expect(actualMeta).toEqual({ v: 2 });
|
||||
|
||||
expect(migrateToV2).toBeCalledTimes(1);
|
||||
expect(migrateToV2).toBeCalledWith(2);
|
||||
});
|
||||
|
||||
it('Should not run old migrations if the version is unchanged', async () => {
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
count: 2,
|
||||
count$: { v: 3 },
|
||||
});
|
||||
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
|
||||
const migrateToV3 = vi.fn((oldCount) => oldCount * 3);
|
||||
|
||||
storage.defineItem<number, { v: number }>(`${storageArea}:count`, {
|
||||
version: 3,
|
||||
migrations: {
|
||||
2: migrateToV2,
|
||||
3: migrateToV3,
|
||||
},
|
||||
});
|
||||
await waitForMigrations();
|
||||
|
||||
expect(migrateToV2).not.toBeCalled();
|
||||
expect(migrateToV3).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should skip missing migration functions', async () => {
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
count: 2,
|
||||
count$: { v: 0 },
|
||||
});
|
||||
const migrateToV1 = vi.fn((oldCount) => oldCount * 1);
|
||||
const migrateToV3 = vi.fn((oldCount) => oldCount * 3);
|
||||
|
||||
const item = storage.defineItem<number, { v: number }>(
|
||||
`${storageArea}:count`,
|
||||
{
|
||||
version: 3,
|
||||
migrations: {
|
||||
1: migrateToV1,
|
||||
3: migrateToV3,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitForMigrations();
|
||||
|
||||
const actualValue = await item.getValue();
|
||||
const actualMeta = await item.getMeta();
|
||||
|
||||
expect(actualValue).toEqual(6);
|
||||
expect(actualMeta).toEqual({ v: 3 });
|
||||
|
||||
expect(migrateToV1).toBeCalledTimes(1);
|
||||
expect(migrateToV1).toBeCalledWith(2);
|
||||
|
||||
expect(migrateToV3).toBeCalledTimes(1);
|
||||
expect(migrateToV3).toBeCalledWith(2);
|
||||
});
|
||||
|
||||
it('should throw an error if the new version is less than the previous version', async () => {
|
||||
const prevVersion = 2;
|
||||
const nextVersion = 1;
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
count: 0,
|
||||
count$: { v: prevVersion },
|
||||
});
|
||||
|
||||
const item = storage.defineItem(`${storageArea}:count`, {
|
||||
version: nextVersion,
|
||||
});
|
||||
|
||||
// @ts-expect-error: _migrationsCompleted is returned, but untyped
|
||||
await expect(item._migrationsCompleted).rejects.toThrow(
|
||||
'version downgrade detected',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValue', () => {
|
||||
it('should return the value from storage', async () => {
|
||||
const expected = 2;
|
||||
const item = storage.defineItem<number>(`${storageArea}:count`);
|
||||
await fakeBrowser.storage[storageArea].set({ count: expected });
|
||||
|
||||
const actual = await item.getValue();
|
||||
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('should return null if missing', async () => {
|
||||
const item = storage.defineItem<number>(`${storageArea}:count`);
|
||||
|
||||
const actual = await item.getValue();
|
||||
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the provided default value if missing', async () => {
|
||||
const expected = 0;
|
||||
const item = storage.defineItem(`${storageArea}:count`, {
|
||||
defaultValue: expected,
|
||||
});
|
||||
|
||||
const actual = await item.getValue();
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMeta', () => {
|
||||
it('should return the value from storage at key+$', async () => {
|
||||
const expected = { v: 2 };
|
||||
const item = storage.defineItem<number, { v: number }>(
|
||||
`${storageArea}:count`,
|
||||
);
|
||||
await fakeBrowser.storage[storageArea].set({ count$: expected });
|
||||
|
||||
const actual = await item.getMeta();
|
||||
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('should return an empty object if missing', async () => {
|
||||
const expected = {};
|
||||
const item = storage.defineItem<number, { v: number }>(
|
||||
`${storageArea}:count`,
|
||||
);
|
||||
|
||||
const actual = await item.getMeta();
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setValue', () => {
|
||||
it('should set the value in storage', async () => {
|
||||
const expected = 1;
|
||||
const item = storage.defineItem<number>(`${storageArea}:count`);
|
||||
|
||||
await item.setValue(expected);
|
||||
const actual = await item.getValue();
|
||||
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([undefined, null])(
|
||||
'should remove the value in storage when %s is passed in',
|
||||
async (value) => {
|
||||
const item = storage.defineItem<number>(`${storageArea}:count`);
|
||||
|
||||
// @ts-expect-error: undefined is not assignable to null, but we're testing that case on purpose
|
||||
await item.setValue(value);
|
||||
const actual = await item.getValue();
|
||||
|
||||
expect(actual).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('setMeta', () => {
|
||||
it('should set metadata at key+$', async () => {
|
||||
const expected = { date: Date.now() };
|
||||
const item = storage.defineItem<number, { date: number }>(
|
||||
`${storageArea}:count`,
|
||||
);
|
||||
|
||||
await item.setMeta(expected);
|
||||
const actual = await item.getMeta();
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should add to metadata if already present', async () => {
|
||||
const existing = { v: 2 };
|
||||
const newFields = { date: Date.now() };
|
||||
const expected = { ...existing, ...newFields };
|
||||
const item = storage.defineItem<
|
||||
number,
|
||||
{ date: number; v: number }
|
||||
>(`${storageArea}:count`);
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
count$: existing,
|
||||
});
|
||||
|
||||
await item.setMeta(newFields);
|
||||
const actual = await item.getMeta();
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeValue', () => {
|
||||
it('should remove the key from storage', async () => {
|
||||
const item = storage.defineItem(`${storageArea}:count`);
|
||||
await fakeBrowser.storage[storageArea].set({ count: 456 });
|
||||
|
||||
await item.removeValue();
|
||||
const actual = await item.getValue();
|
||||
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
|
||||
it('should not remove the metadata by default', async () => {
|
||||
const item = storage.defineItem(`${storageArea}:count`);
|
||||
const expected = { v: 1 };
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
count$: expected,
|
||||
count: 3,
|
||||
});
|
||||
|
||||
await item.removeValue();
|
||||
const actual = await item.getMeta();
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should remove the metadata when requested', async () => {
|
||||
const item = storage.defineItem(`${storageArea}:count`);
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
count$: { v: 1 },
|
||||
count: 3,
|
||||
});
|
||||
|
||||
await item.removeValue({ removeMeta: true });
|
||||
const actual = await item.getMeta();
|
||||
|
||||
expect(actual).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMeta', () => {
|
||||
it('should remove all metadata', async () => {
|
||||
const item = storage.defineItem<number, { v: number }>(
|
||||
`${storageArea}:count`,
|
||||
);
|
||||
await fakeBrowser.storage[storageArea].set({ count$: { v: 4 } });
|
||||
|
||||
await item.removeMeta();
|
||||
const actual = await item.getMeta();
|
||||
|
||||
expect(actual).toEqual({});
|
||||
});
|
||||
|
||||
it('should only remove specific properties', async () => {
|
||||
const item = storage.defineItem<number, { v: number; d: number }>(
|
||||
`${storageArea}:count`,
|
||||
);
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
count$: { v: 4, d: Date.now() },
|
||||
});
|
||||
|
||||
await item.removeMeta(['d']);
|
||||
const actual = await item.getMeta();
|
||||
|
||||
expect(actual).toEqual({ v: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('watch', () => {
|
||||
it("should not trigger if the changed key is different from the item's key", async () => {
|
||||
const item = storage.defineItem(`${storageArea}:key`);
|
||||
const cb = vi.fn();
|
||||
|
||||
item.watch(cb);
|
||||
await storage.setItem(`${storageArea}:not-the-key`, '123');
|
||||
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("should not trigger if the value doesn't change", async () => {
|
||||
const item = storage.defineItem(`${storageArea}:key`);
|
||||
const cb = vi.fn();
|
||||
const value = '123';
|
||||
|
||||
await item.setValue(value);
|
||||
item.watch(cb);
|
||||
await item.setValue(value);
|
||||
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should call the callback when the value changes', async () => {
|
||||
const item = storage.defineItem(`${storageArea}:key`);
|
||||
const cb = vi.fn();
|
||||
const newValue = '123';
|
||||
const oldValue = null;
|
||||
|
||||
item.watch(cb);
|
||||
await item.setValue(newValue);
|
||||
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
expect(cb).toBeCalledWith(newValue, oldValue);
|
||||
});
|
||||
|
||||
it('should remove the listener when calling the returned function', async () => {
|
||||
const item = storage.defineItem(`${storageArea}:key`);
|
||||
const cb = vi.fn();
|
||||
|
||||
const unwatch = item.watch(cb);
|
||||
unwatch();
|
||||
await item.setValue('123');
|
||||
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unwatch', () => {
|
||||
it('should remove all watch listeners', async () => {
|
||||
const item = storage.defineItem(`${storageArea}:key`);
|
||||
const cb = vi.fn();
|
||||
|
||||
item.watch(cb);
|
||||
storage.unwatch();
|
||||
await item.setValue('123');
|
||||
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@@ -17,3 +17,65 @@ export interface WxtI18n extends I18n.Static {
|
||||
}
|
||||
|
||||
export const browser: AugmentedBrowser = originalBrowser;
|
||||
|
||||
// re-export all the types from webextension-polyfill
|
||||
// Because webextension-polyfill uses a weird namespace with "import export", there isn't a good way
|
||||
// to get these types without re-listing them.
|
||||
/** @ignore */
|
||||
export type {
|
||||
ActivityLog,
|
||||
Alarms,
|
||||
Bookmarks,
|
||||
Action,
|
||||
BrowserAction,
|
||||
BrowserSettings,
|
||||
BrowsingData,
|
||||
CaptivePortal,
|
||||
Clipboard,
|
||||
Commands,
|
||||
ContentScripts,
|
||||
ContextualIdentities,
|
||||
Cookies,
|
||||
DeclarativeNetRequest,
|
||||
Devtools,
|
||||
Dns,
|
||||
Downloads,
|
||||
Events,
|
||||
Experiments,
|
||||
Extension,
|
||||
ExtensionTypes,
|
||||
Find,
|
||||
GeckoProfiler,
|
||||
History,
|
||||
I18n,
|
||||
Identity,
|
||||
Idle,
|
||||
Management,
|
||||
Manifest,
|
||||
ContextMenus,
|
||||
Menus,
|
||||
NetworkStatus,
|
||||
NormandyAddonStudy,
|
||||
Notifications,
|
||||
Omnibox,
|
||||
PageAction,
|
||||
Permissions,
|
||||
Pkcs11,
|
||||
Privacy,
|
||||
Proxy,
|
||||
Runtime,
|
||||
Scripting,
|
||||
Search,
|
||||
Sessions,
|
||||
SidebarAction,
|
||||
Storage,
|
||||
Tabs,
|
||||
Theme,
|
||||
TopSites,
|
||||
Types,
|
||||
Urlbar,
|
||||
UserScripts,
|
||||
WebNavigation,
|
||||
WebRequest,
|
||||
Windows,
|
||||
} from 'webextension-polyfill';
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import { describe, it, vi, beforeEach, expect } from 'vitest';
|
||||
import { build } from '~/core/build';
|
||||
import { createServer } from '~/core/create-server';
|
||||
import { zip } from '~/core/zip';
|
||||
import { prepare } from '~/core/prepare';
|
||||
import { clean } from '~/core/clean';
|
||||
import { initialize } from '~/core/initialize';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import consola from 'consola';
|
||||
|
||||
vi.mock('~/core/build');
|
||||
const buildMock = vi.mocked(build);
|
||||
|
||||
vi.mock('~/core/create-server');
|
||||
const createServerMock = vi.mocked(createServer);
|
||||
|
||||
vi.mock('~/core/zip');
|
||||
const zipMock = vi.mocked(zip);
|
||||
|
||||
vi.mock('~/core/prepare');
|
||||
const prepareMock = vi.mocked(prepare);
|
||||
|
||||
vi.mock('~/core/clean');
|
||||
const cleanMock = vi.mocked(clean);
|
||||
|
||||
vi.mock('~/core/initialize');
|
||||
const initializeMock = vi.mocked(initialize);
|
||||
|
||||
consola.wrapConsole();
|
||||
|
||||
const ogArgv = process.argv;
|
||||
|
||||
function mockArgv(...args: string[]) {
|
||||
process.argv = ['/bin/node', 'bin/wxt.mjs', ...args];
|
||||
}
|
||||
|
||||
async function importCli() {
|
||||
await import('~/cli');
|
||||
}
|
||||
|
||||
describe('CLI', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.argv = ogArgv;
|
||||
createServerMock.mockResolvedValue(mock());
|
||||
});
|
||||
|
||||
describe('dev', () => {
|
||||
it('should not pass any config when no flags are passed', async () => {
|
||||
mockArgv();
|
||||
await importCli();
|
||||
|
||||
expect(createServerMock).toBeCalledWith({});
|
||||
});
|
||||
|
||||
it('should respect passing a custom root', async () => {
|
||||
mockArgv('path/to/root');
|
||||
await importCli();
|
||||
|
||||
expect(createServerMock).toBeCalledWith({
|
||||
root: 'path/to/root',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect a custom config file', async () => {
|
||||
mockArgv('-c', './path/to/config.ts');
|
||||
await importCli();
|
||||
|
||||
expect(createServerMock).toBeCalledWith({
|
||||
configFile: './path/to/config.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing a custom mode', async () => {
|
||||
mockArgv('-m', 'development');
|
||||
await importCli();
|
||||
|
||||
expect(createServerMock).toBeCalledWith({
|
||||
mode: 'development',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing a custom browser', async () => {
|
||||
mockArgv('-b', 'firefox');
|
||||
await importCli();
|
||||
|
||||
expect(createServerMock).toBeCalledWith({
|
||||
browser: 'firefox',
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass correct filtered entrypoints', async () => {
|
||||
mockArgv('-e', 'popup', '-e', 'options');
|
||||
await importCli();
|
||||
|
||||
expect(createServerMock).toBeCalledWith({
|
||||
filterEntrypoints: ['popup', 'options'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --mv2', async () => {
|
||||
mockArgv('--mv2');
|
||||
await importCli();
|
||||
|
||||
expect(createServerMock).toBeCalledWith({
|
||||
manifestVersion: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --mv3', async () => {
|
||||
mockArgv('--mv3');
|
||||
await importCli();
|
||||
|
||||
expect(createServerMock).toBeCalledWith({
|
||||
manifestVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --debug', async () => {
|
||||
mockArgv('--debug');
|
||||
await importCli();
|
||||
|
||||
expect(createServerMock).toBeCalledWith({
|
||||
debug: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('build', () => {
|
||||
it('should not pass any config when no flags are passed', async () => {
|
||||
mockArgv('build');
|
||||
await importCli();
|
||||
|
||||
expect(buildMock).toBeCalledWith({});
|
||||
});
|
||||
|
||||
it('should respect passing a custom root', async () => {
|
||||
mockArgv('build', 'path/to/root');
|
||||
await importCli();
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
root: 'path/to/root',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect a custom config file', async () => {
|
||||
mockArgv('build', '-c', './path/to/config.ts');
|
||||
await importCli();
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
configFile: './path/to/config.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing a custom mode', async () => {
|
||||
mockArgv('build', '-m', 'development');
|
||||
await importCli();
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
mode: 'development',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing a custom browser', async () => {
|
||||
mockArgv('build', '-b', 'firefox');
|
||||
await importCli();
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
browser: 'firefox',
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass correct filtered entrypoints', async () => {
|
||||
mockArgv('build', '-e', 'popup', '-e', 'options');
|
||||
await importCli();
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
filterEntrypoints: ['popup', 'options'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --mv2', async () => {
|
||||
mockArgv('build', '--mv2');
|
||||
await importCli();
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
manifestVersion: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --mv3', async () => {
|
||||
mockArgv('build', '--mv3');
|
||||
await importCli();
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
manifestVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include analysis in the build', async () => {
|
||||
mockArgv('build', '--analyze');
|
||||
await importCli();
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
analysis: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --debug', async () => {
|
||||
mockArgv('build', '--debug');
|
||||
await importCli();
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
debug: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('zip', () => {
|
||||
it('should not pass any config when no flags are passed', async () => {
|
||||
mockArgv('zip');
|
||||
await importCli();
|
||||
|
||||
expect(zipMock).toBeCalledWith({});
|
||||
});
|
||||
|
||||
it('should respect passing a custom root', async () => {
|
||||
mockArgv('zip', 'path/to/root');
|
||||
await importCli();
|
||||
|
||||
expect(zipMock).toBeCalledWith({
|
||||
root: 'path/to/root',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect a custom config file', async () => {
|
||||
mockArgv('zip', '-c', './path/to/config.ts');
|
||||
await importCli();
|
||||
|
||||
expect(zipMock).toBeCalledWith({
|
||||
configFile: './path/to/config.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing a custom mode', async () => {
|
||||
mockArgv('zip', '-m', 'development');
|
||||
await importCli();
|
||||
|
||||
expect(zipMock).toBeCalledWith({
|
||||
mode: 'development',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing a custom browser', async () => {
|
||||
mockArgv('zip', '-b', 'firefox');
|
||||
await importCli();
|
||||
|
||||
expect(zipMock).toBeCalledWith({
|
||||
browser: 'firefox',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --mv2', async () => {
|
||||
mockArgv('zip', '--mv2');
|
||||
await importCli();
|
||||
|
||||
expect(zipMock).toBeCalledWith({
|
||||
manifestVersion: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --mv3', async () => {
|
||||
mockArgv('zip', '--mv3');
|
||||
await importCli();
|
||||
|
||||
expect(zipMock).toBeCalledWith({
|
||||
manifestVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --debug', async () => {
|
||||
mockArgv('zip', '--debug');
|
||||
await importCli();
|
||||
|
||||
expect(zipMock).toBeCalledWith({
|
||||
debug: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepare', () => {
|
||||
it('should not pass any config when no flags are passed', async () => {
|
||||
mockArgv('prepare');
|
||||
await importCli();
|
||||
|
||||
expect(prepareMock).toBeCalledWith({});
|
||||
});
|
||||
|
||||
it('should respect passing a custom root', async () => {
|
||||
mockArgv('prepare', 'path/to/root');
|
||||
await importCli();
|
||||
|
||||
expect(prepareMock).toBeCalledWith({
|
||||
root: 'path/to/root',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect a custom config file', async () => {
|
||||
mockArgv('prepare', '-c', './path/to/config.ts');
|
||||
await importCli();
|
||||
|
||||
expect(prepareMock).toBeCalledWith({
|
||||
configFile: './path/to/config.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --debug', async () => {
|
||||
mockArgv('prepare', '--debug');
|
||||
await importCli();
|
||||
|
||||
expect(prepareMock).toBeCalledWith({
|
||||
debug: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clean', () => {
|
||||
it('should not pass any config when no flags are passed', async () => {
|
||||
mockArgv('clean');
|
||||
await importCli();
|
||||
|
||||
expect(cleanMock).toBeCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('should respect passing a custom root', async () => {
|
||||
mockArgv('clean', 'path/to/root');
|
||||
await importCli();
|
||||
|
||||
expect(cleanMock).toBeCalledWith('path/to/root');
|
||||
});
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should not pass any options when no flags are passed', async () => {
|
||||
mockArgv('init');
|
||||
await importCli();
|
||||
|
||||
expect(initializeMock).toBeCalledWith({});
|
||||
});
|
||||
|
||||
it('should respect the provided folder', async () => {
|
||||
mockArgv('init', 'path/to/folder');
|
||||
await importCli();
|
||||
|
||||
expect(initializeMock).toBeCalledWith({
|
||||
directory: 'path/to/folder',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --template', async () => {
|
||||
mockArgv('init', '-t', 'vue');
|
||||
await importCli();
|
||||
|
||||
expect(initializeMock).toBeCalledWith({
|
||||
template: 'vue',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --pm', async () => {
|
||||
mockArgv('init', '--pm', 'pnpm');
|
||||
await importCli();
|
||||
|
||||
expect(initializeMock).toBeCalledWith({
|
||||
packageManager: 'pnpm',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { CAC, Command } from 'cac';
|
||||
import consola, { LogLevels } from 'consola';
|
||||
import { exec } from '~/core/utils/exec';
|
||||
import { printHeader } from '~/core/utils/log';
|
||||
import { formatDuration } from '~/core/utils/time';
|
||||
import { ValidationError } from '~/core/utils/validation';
|
||||
import { registerWxt } from '~/core/wxt';
|
||||
|
||||
/**
|
||||
* Wrap an action handler to add a timer, error handling, and maybe enable debug mode.
|
||||
*/
|
||||
export function wrapAction(
|
||||
cb: (
|
||||
...args: any[]
|
||||
) => void | { isOngoing?: boolean } | Promise<void | { isOngoing?: boolean }>,
|
||||
options?: {
|
||||
disableFinishedLog?: boolean;
|
||||
},
|
||||
) {
|
||||
return async (...args: any[]) => {
|
||||
// Enable consola's debug mode globally at the start of all commands when the `--debug` flag is
|
||||
// passed
|
||||
const isDebug = !!args.find((arg) => arg?.debug);
|
||||
if (isDebug) {
|
||||
consola.level = LogLevels.debug;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
printHeader();
|
||||
|
||||
const status = await cb(...args);
|
||||
|
||||
if (!status?.isOngoing && !options?.disableFinishedLog)
|
||||
consola.success(
|
||||
`Finished in ${formatDuration(Date.now() - startTime)}`,
|
||||
);
|
||||
} catch (err) {
|
||||
consola.fail(
|
||||
`Command failed after ${formatDuration(Date.now() - startTime)}`,
|
||||
);
|
||||
if (err instanceof ValidationError) {
|
||||
// Don't log these errors, they've already been logged
|
||||
} else {
|
||||
consola.error(err);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Array flags, when not passed, are either `undefined` or `[undefined]`. This function filters out
|
||||
* the
|
||||
*/
|
||||
export function getArrayFromFlags<T>(
|
||||
flags: any,
|
||||
name: string,
|
||||
): T[] | undefined {
|
||||
const array = [flags[name]].flat() as Array<T | undefined>;
|
||||
const result = array.filter((item) => item != null) as T[];
|
||||
return result.length ? result : undefined;
|
||||
}
|
||||
|
||||
const aliasCommandNames = new Set<string>();
|
||||
export function createAliasedCommand(
|
||||
base: CAC,
|
||||
name: string,
|
||||
alias: string,
|
||||
docsUrl: string,
|
||||
) {
|
||||
const aliasedCommand = base
|
||||
.command(name, `Alias for ${alias} (${docsUrl})`)
|
||||
.allowUnknownOptions()
|
||||
.action(async () => {
|
||||
try {
|
||||
await registerWxt('build');
|
||||
|
||||
const args = process.argv.slice(
|
||||
process.argv.indexOf(aliasedCommand.name) + 1,
|
||||
);
|
||||
await exec(alias, args, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} catch {
|
||||
// Let the other aliased CLI log errors, just exit
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
aliasCommandNames.add(aliasedCommand.name);
|
||||
}
|
||||
export function isAliasedCommand(command: Command | undefined): boolean {
|
||||
return !!command && aliasCommandNames.has(command.name);
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import cac from 'cac';
|
||||
import { version } from '~/version';
|
||||
import { build, clean, createServer, initialize, prepare, zip } from '~/core';
|
||||
import consola, { LogLevels } from 'consola';
|
||||
import { printHeader } from '~/core/utils/log';
|
||||
import { formatDuration } from '~/core/utils/time';
|
||||
import {
|
||||
createAliasedCommand,
|
||||
getArrayFromFlags,
|
||||
wrapAction,
|
||||
} from './cli-utils';
|
||||
|
||||
const cli = cac('wxt');
|
||||
cli.help();
|
||||
cli.version(version);
|
||||
|
||||
cli.option('--debug', 'enable debug mode');
|
||||
|
||||
@@ -17,6 +16,13 @@ cli
|
||||
.option('-c, --config <file>', 'use specified config file')
|
||||
.option('-m, --mode <mode>', 'set env mode')
|
||||
.option('-b, --browser <browser>', 'specify a browser')
|
||||
.option(
|
||||
'-e, --filter-entrypoint <entrypoint>',
|
||||
'only build specific entrypoints',
|
||||
{
|
||||
type: [],
|
||||
},
|
||||
)
|
||||
.option('--mv3', 'target manifest v3')
|
||||
.option('--mv2', 'target manifest v2')
|
||||
.action(
|
||||
@@ -28,6 +34,7 @@ cli
|
||||
manifestVersion: flags.mv3 ? 3 : flags.mv2 ? 2 : undefined,
|
||||
configFile: flags.config,
|
||||
debug: flags.debug,
|
||||
filterEntrypoints: getArrayFromFlags(flags, 'filterEntrypoint'),
|
||||
});
|
||||
await server.start();
|
||||
return { isOngoing: true };
|
||||
@@ -40,6 +47,13 @@ cli
|
||||
.option('-c, --config <file>', 'use specified config file')
|
||||
.option('-m, --mode <mode>', 'set env mode')
|
||||
.option('-b, --browser <browser>', 'specify a browser')
|
||||
.option(
|
||||
'-e, --filter-entrypoint <entrypoint>',
|
||||
'only build specific entrypoints',
|
||||
{
|
||||
type: [],
|
||||
},
|
||||
)
|
||||
.option('--mv3', 'target manifest v3')
|
||||
.option('--mv2', 'target manifest v2')
|
||||
.option('--analyze', 'visualize extension bundle')
|
||||
@@ -52,9 +66,8 @@ cli
|
||||
manifestVersion: flags.mv3 ? 3 : flags.mv2 ? 2 : undefined,
|
||||
configFile: flags.config,
|
||||
debug: flags.debug,
|
||||
analysis: {
|
||||
enabled: flags.analyze,
|
||||
},
|
||||
analysis: flags.analyze ? { enabled: true } : undefined,
|
||||
filterEntrypoints: getArrayFromFlags(flags, 'filterEntrypoint'),
|
||||
});
|
||||
}),
|
||||
);
|
||||
@@ -122,43 +135,12 @@ cli
|
||||
),
|
||||
);
|
||||
|
||||
cli.parse();
|
||||
// SUBMIT
|
||||
createAliasedCommand(
|
||||
cli,
|
||||
'submit',
|
||||
'publish-extension',
|
||||
'https://www.npmjs.com/publish-browser-extension',
|
||||
);
|
||||
|
||||
/**
|
||||
* Wrap an action handler to add a timer, error handling, and maybe enable debug mode.
|
||||
*/
|
||||
function wrapAction(
|
||||
cb: (
|
||||
...args: any[]
|
||||
) => void | { isOngoing?: boolean } | Promise<void | { isOngoing?: boolean }>,
|
||||
options?: {
|
||||
disableFinishedLog?: boolean;
|
||||
},
|
||||
) {
|
||||
return async (...args: any[]) => {
|
||||
// Enable consola's debug mode globally at the start of all commands when the `--debug` flag is
|
||||
// passed
|
||||
const isDebug = !!args.find((arg) => arg?.debug);
|
||||
if (isDebug) {
|
||||
consola.level = LogLevels.debug;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
printHeader();
|
||||
|
||||
const status = await cb(...args);
|
||||
|
||||
if (!status?.isOngoing && !options?.disableFinishedLog)
|
||||
consola.success(
|
||||
`Finished in ${formatDuration(Date.now() - startTime)}`,
|
||||
);
|
||||
} catch (err) {
|
||||
consola.fail(
|
||||
`Command failed after ${formatDuration(Date.now() - startTime)}`,
|
||||
);
|
||||
consola.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
export default cli;
|
||||
@@ -0,0 +1,19 @@
|
||||
import cli from './commands';
|
||||
import { version } from '~/version';
|
||||
import { isAliasedCommand } from './cli-utils';
|
||||
|
||||
// TODO: Remove. See https://github.com/wxt-dev/wxt/issues/277
|
||||
process.env.VITE_CJS_IGNORE_WARNING = 'true';
|
||||
|
||||
// Grab the command that we're trying to run
|
||||
cli.parse(process.argv, { run: false });
|
||||
|
||||
// If it's not an alias, add the help and version options, then parse again
|
||||
if (!isAliasedCommand(cli.matchedCommand)) {
|
||||
cli.help();
|
||||
cli.version(version);
|
||||
cli.parse(process.argv, { run: false });
|
||||
}
|
||||
|
||||
// Run the alias or command
|
||||
await cli.runMatchedCommand();
|
||||
@@ -1,214 +0,0 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createContentScriptIframe } from '~/client/content-scripts/content-script-iframe';
|
||||
import { ContentScriptContext } from '~/client/content-scripts/content-script-context';
|
||||
|
||||
const createCtx = () => new ContentScriptContext('test');
|
||||
|
||||
const fetch = vi.fn();
|
||||
|
||||
describe('createContentScriptIframe', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="parent">
|
||||
<p id="one">one</p>
|
||||
<p id="two">two</p>
|
||||
<p id="three"></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
window.fetch = fetch;
|
||||
fetch.mockResolvedValue({ text: () => Promise.resolve('') });
|
||||
});
|
||||
|
||||
describe('mount', () => {
|
||||
describe('append option', () => {
|
||||
it.each([undefined, 'last' as const])(
|
||||
'should append the element as the last child to the anchor when append=%s',
|
||||
async (append) => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor: '#parent',
|
||||
append,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(4)')?.tagName,
|
||||
).toEqual('DIV');
|
||||
},
|
||||
);
|
||||
|
||||
it('should append the element as the first child to the anchor when append=first', async () => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor: '#parent',
|
||||
append: 'first',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(1)')?.tagName,
|
||||
).toEqual('DIV');
|
||||
});
|
||||
|
||||
it('should append the element normally when append=first but there are no other children', async () => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor: '#three',
|
||||
append: 'first',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#three > :nth-child(1)')?.tagName,
|
||||
).toEqual('DIV');
|
||||
});
|
||||
|
||||
it('should replace the anchor with the element when append=replace', async () => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor: '#two',
|
||||
append: 'replace',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(2)')?.tagName,
|
||||
).toEqual('DIV');
|
||||
});
|
||||
|
||||
it('should append the element after the anchor when append=before', async () => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor: '#two',
|
||||
append: 'before',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(2)')?.tagName,
|
||||
).toEqual('DIV');
|
||||
});
|
||||
|
||||
it('should append the element after the anchor when append=after', async () => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor: '#two',
|
||||
append: 'after',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
// Happy DOM doesn't work in this case, so we just make sure the element is added.
|
||||
// expect(
|
||||
// document.querySelector('#parent > :nth-child(3)')?.tagName,
|
||||
// ).toEqual('DIV');
|
||||
expect(document.querySelector('DIV')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should apply a custom function', async () => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor: '#three',
|
||||
append: (anchor, ui) => anchor.replaceWith(ui),
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(3)')?.tagName,
|
||||
).toEqual('DIV');
|
||||
});
|
||||
});
|
||||
|
||||
it('should default the anchor to the body when unset', async () => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('body > :nth-child(2)')?.tagName).toEqual(
|
||||
'DIV',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['#four', () => document.querySelector('#four')])(
|
||||
"should throw an error if the anchor doesn't exist",
|
||||
async (anchor) => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor,
|
||||
});
|
||||
|
||||
expect(ui.mount).toThrow(
|
||||
'Failed to mount content script UI: could not find anchor element',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it("should not fail if the ui hasn't been mounted", async () => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
});
|
||||
ui.remove();
|
||||
});
|
||||
|
||||
it('should automatically remove the UI when the context is invalidated', async () => {
|
||||
const ctx = createCtx();
|
||||
const ui = await createContentScriptIframe(ctx, {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('.wxt-iframe-wrapper')).toBeDefined();
|
||||
|
||||
ctx.abort();
|
||||
expect(document.querySelector('.wxt-iframe-wrapper')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('type', () => {
|
||||
it.each(['inline', 'overlay', 'modal'] as const)(
|
||||
'should render type=%s',
|
||||
async (type) => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('DIV')).toBeDefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('anchor', () => {
|
||||
it.each([
|
||||
() => '#two',
|
||||
() => () => '#two',
|
||||
() => document.querySelector('#two'),
|
||||
() => () => document.querySelector('#two'),
|
||||
])('should render anchor=%s', async (getAnchor) => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor: getAnchor(),
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('DIV')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,335 +0,0 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createContentScriptUi } from '~/client/content-scripts/content-script-ui';
|
||||
import { ContentScriptContext } from '~/client/content-scripts/content-script-context';
|
||||
import { createIsolatedElement } from '@webext-core/isolated-element';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { browser } from '~/browser';
|
||||
|
||||
vi.mock('@webext-core/isolated-element', async () => {
|
||||
const { vi } = await import('vitest');
|
||||
return {
|
||||
createIsolatedElement: vi.fn(),
|
||||
};
|
||||
});
|
||||
const createIsolatedElementMock = vi.mocked(createIsolatedElement);
|
||||
|
||||
const testApp = (container: Element) => {
|
||||
const app = document.createElement('div');
|
||||
app.textContent = 'App';
|
||||
container.append(app);
|
||||
};
|
||||
|
||||
const createCtx = () => new ContentScriptContext('test');
|
||||
|
||||
const fetch = vi.fn();
|
||||
|
||||
describe('createContentScriptUi', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="parent">
|
||||
<p id="one">one</p>
|
||||
<p id="two">two</p>
|
||||
<p id="three"></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
window.fetch = fetch;
|
||||
fetch.mockResolvedValue({ text: () => Promise.resolve('') });
|
||||
|
||||
createIsolatedElementMock.mockImplementation(async (config) => {
|
||||
const parentElement = document.createElement(config.name);
|
||||
const isolatedElement = document.createElement('html');
|
||||
parentElement.append(isolatedElement);
|
||||
|
||||
return {
|
||||
isolatedElement,
|
||||
parentElement: parentElement,
|
||||
shadow: mock<ShadowRoot>({
|
||||
querySelector: (selector: string) => {
|
||||
if (selector === 'html') return isolatedElement;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
describe('css', () => {
|
||||
it('should load the CSS for the current entrypoint when cssInjectionMode=ui', async () => {
|
||||
fetch.mockResolvedValue({ text: () => Promise.resolve('body {}') });
|
||||
const ctx = new ContentScriptContext('test', {
|
||||
matches: [],
|
||||
cssInjectionMode: 'ui',
|
||||
});
|
||||
|
||||
await createContentScriptUi(ctx, {
|
||||
name: 'test',
|
||||
type: 'inline',
|
||||
mount: testApp,
|
||||
});
|
||||
|
||||
expect(fetch).toBeCalledTimes(1);
|
||||
expect(fetch).toBeCalledWith(
|
||||
`chrome-extension://${browser.runtime.id}/content-scripts/${__ENTRYPOINT__}.css`,
|
||||
);
|
||||
expect(createIsolatedElementMock).toBeCalledTimes(1);
|
||||
expect(createIsolatedElementMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
css: {
|
||||
textContent: 'body {}',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should still load the UI when fetch fails to load CSS file when cssInjectionMode=ui', async () => {
|
||||
const error = Error('Test fetch error');
|
||||
fetch.mockRejectedValue(error);
|
||||
const ctx = new ContentScriptContext('test', {
|
||||
matches: [],
|
||||
cssInjectionMode: 'ui',
|
||||
});
|
||||
|
||||
await createContentScriptUi(ctx, {
|
||||
name: 'test',
|
||||
type: 'inline',
|
||||
mount: testApp,
|
||||
});
|
||||
|
||||
expect(fetch).toBeCalledTimes(1);
|
||||
expect(fetch).toBeCalledWith(
|
||||
`chrome-extension://${browser.runtime.id}/content-scripts/${__ENTRYPOINT__}.css`,
|
||||
);
|
||||
expect(createIsolatedElementMock).toBeCalledTimes(1);
|
||||
expect(createIsolatedElementMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
css: {
|
||||
textContent: '',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['manifest', 'manual'] as const)(
|
||||
'should not fetch CSS when cssInjectionMode=%s',
|
||||
async (cssInjectionMode) => {
|
||||
const ctx = new ContentScriptContext('test', {
|
||||
matches: [],
|
||||
cssInjectionMode,
|
||||
});
|
||||
|
||||
await createContentScriptUi(ctx, {
|
||||
name: 'test',
|
||||
type: 'inline',
|
||||
mount: testApp,
|
||||
});
|
||||
|
||||
expect(fetch).not.toBeCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('mount', () => {
|
||||
describe('append option', () => {
|
||||
it.each([undefined, 'last' as const])(
|
||||
'should append the element as the last child to the anchor when append=%s',
|
||||
async (append) => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
anchor: '#parent',
|
||||
append,
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(4)')?.tagName,
|
||||
).toEqual('TEST-APP');
|
||||
},
|
||||
);
|
||||
|
||||
it('should append the element as the first child to the anchor when append=first', async () => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
anchor: '#parent',
|
||||
append: 'first',
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(1)')?.tagName,
|
||||
).toEqual('TEST-APP');
|
||||
});
|
||||
|
||||
it('should append the element normally when append=first but there are no other children', async () => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
anchor: '#three',
|
||||
append: 'first',
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#three > :nth-child(1)')?.tagName,
|
||||
).toEqual('TEST-APP');
|
||||
});
|
||||
|
||||
it('should replace the anchor with the element when append=replace', async () => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
anchor: '#two',
|
||||
append: 'replace',
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(2)')?.tagName,
|
||||
).toEqual('TEST-APP');
|
||||
});
|
||||
|
||||
it('should append the element after the anchor when append=before', async () => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
anchor: '#two',
|
||||
append: 'before',
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(2)')?.tagName,
|
||||
).toEqual('TEST-APP');
|
||||
});
|
||||
|
||||
it('should append the element after the anchor when append=after', async () => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
anchor: '#two',
|
||||
append: 'after',
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
// Happy DOM doesn't work in this case, so we just make sure the element is added.
|
||||
// expect(
|
||||
// document.querySelector('#parent > :nth-child(3)')?.tagName,
|
||||
// ).toEqual('TEST-APP');
|
||||
expect(document.querySelector('test-app')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should apply a custom function', async () => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
anchor: '#three',
|
||||
append: (anchor, ui) => anchor.replaceWith(ui),
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(3)')?.tagName,
|
||||
).toEqual('TEST-APP');
|
||||
});
|
||||
});
|
||||
|
||||
it('should default the anchor to the body when unset', async () => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('body > :nth-child(2)')?.tagName).toEqual(
|
||||
'TEST-APP',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['#four', () => document.querySelector('#four')])(
|
||||
"should throw an error if the anchor doesn't exist",
|
||||
async (anchor) => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test',
|
||||
type: 'inline',
|
||||
anchor,
|
||||
mount: testApp,
|
||||
});
|
||||
|
||||
expect(ui.mount).toThrow(
|
||||
'Failed to mount content script UI: could not find anchor element',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it("should not fail if the ui hasn't been mounted", async () => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
mount: testApp,
|
||||
});
|
||||
ui.remove();
|
||||
});
|
||||
|
||||
it('should automatically remove the UI when the context is invalidated', async () => {
|
||||
const ctx = createCtx();
|
||||
const ui = await createContentScriptUi(ctx, {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('test-app')).toBeDefined();
|
||||
|
||||
ctx.abort();
|
||||
expect(document.querySelector('test-app')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('type', () => {
|
||||
it.each(['inline', 'overlay', 'modal'] as const)(
|
||||
'should render type=%s',
|
||||
async (type) => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type,
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('test-app')).toBeDefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('anchor', () => {
|
||||
it.each([
|
||||
() => '#two',
|
||||
() => () => '#two',
|
||||
() => document.querySelector('#two'),
|
||||
() => () => document.querySelector('#two'),
|
||||
])('should render anchor=%s', async (getAnchor) => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
anchor: getAnchor(),
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('test-app')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ContentScriptDefinition } from '~/types';
|
||||
import { browser } from '~/browser';
|
||||
import { logger } from '~/client/utils/logger';
|
||||
import { logger } from '~/sandbox/utils/logger';
|
||||
import { WxtLocationChangeEvent, getUniqueEventName } from './custom-events';
|
||||
import { createLocationWatcher } from './location-watcher';
|
||||
|
||||
/**
|
||||
* Implements [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController).
|
||||
@@ -14,6 +16,7 @@ export class ContentScriptContext implements AbortController {
|
||||
|
||||
#isTopFrame = window.self === window.top;
|
||||
#abortController: AbortController;
|
||||
#locationWatcher = createLocationWatcher(this);
|
||||
|
||||
constructor(
|
||||
private readonly contentScriptName: string,
|
||||
@@ -137,23 +140,42 @@ export class ContentScriptContext implements AbortController {
|
||||
/**
|
||||
* Call `target.addEventListener` and remove the event listener when the context is invalidated.
|
||||
*
|
||||
* Includes additional events useful for content scripts:
|
||||
*
|
||||
* - `"wxt:locationchange"` - Triggered when HTML5 history mode is used to change URL. Content
|
||||
* scripts are not reloaded when navigating this way, so this can be used to reset the content
|
||||
* script state on URL change, or run custom code.
|
||||
*
|
||||
* @example
|
||||
* ctx.addEventListener(window, "mousemove", () => {
|
||||
* // ...
|
||||
* });
|
||||
* ctx.addEventListener(document, "visibilitychange", () => {
|
||||
* // ...
|
||||
* });
|
||||
* ctx.addEventListener(document, "wxt:locationchange", () => {
|
||||
* // ...
|
||||
* });
|
||||
*/
|
||||
addEventListener(
|
||||
target: any,
|
||||
type: string,
|
||||
handler: (event: Event) => void,
|
||||
addEventListener<
|
||||
TTarget extends EventTarget,
|
||||
TType extends keyof WxtContentScriptEventMap,
|
||||
>(
|
||||
target: TTarget,
|
||||
type: TType,
|
||||
handler: (event: WxtContentScriptEventMap[TType]) => void,
|
||||
options?: AddEventListenerOptions,
|
||||
) {
|
||||
target.addEventListener?.(type, handler, options);
|
||||
this.onInvalidated(
|
||||
() => target.removeEventListener?.(type, handler, options),
|
||||
if (type === 'wxt:locationchange') {
|
||||
// Start the location watcher when adding the event for the first time
|
||||
if (this.isValid) this.#locationWatcher.run();
|
||||
}
|
||||
|
||||
target.addEventListener?.(
|
||||
type.startsWith('wxt:') ? getUniqueEventName(type) : type,
|
||||
// @ts-expect-error: Event don't match, but that's OK, EventTarget doesn't allow custom types in the callback
|
||||
handler,
|
||||
{
|
||||
...options,
|
||||
signal: this.signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,3 +215,7 @@ export class ContentScriptContext implements AbortController {
|
||||
this.onInvalidated(() => removeEventListener('message', cb));
|
||||
}
|
||||
}
|
||||
|
||||
interface WxtContentScriptEventMap extends WindowEventMap {
|
||||
'wxt:locationchange': WxtLocationChangeEvent;
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import browser from 'webextension-polyfill';
|
||||
import {
|
||||
ContentScriptAnchoredOptions,
|
||||
ContentScriptPositioningOptions,
|
||||
applyContentScriptUiPosition,
|
||||
mountContentScriptUiRoot,
|
||||
} from '../utils/content-script-ui';
|
||||
import { ContentScriptContext } from './content-script-context';
|
||||
|
||||
/**
|
||||
* Utility for mounting a content script UI inside an iframe. Automatically removed from the DOM
|
||||
* when the content script's context is invalidated.
|
||||
*
|
||||
* See https://wxt.dev/entrypoints/content-scripts.html#iframe for full documentation.
|
||||
*
|
||||
* @example
|
||||
* export default defineContentScript({
|
||||
* matches: ["*://*.google.com/*"],
|
||||
*
|
||||
* main(ctx) {
|
||||
* const ui = await createContentScriptIframe(ctx, {
|
||||
* page: "/content-script-overlay.html",
|
||||
* type: "modal",
|
||||
* })
|
||||
* ui.mount();
|
||||
* }
|
||||
* })
|
||||
*/
|
||||
export function createContentScriptIframe(
|
||||
ctx: ContentScriptContext,
|
||||
options: ContentScriptIframeOptions,
|
||||
): ContentScriptIframe {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.classList.add('wxt-iframe-wrapper');
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = browser.runtime.getURL(options.page);
|
||||
wrapper.appendChild(iframe);
|
||||
|
||||
const mount = () => {
|
||||
applyContentScriptUiPosition(wrapper, iframe, options);
|
||||
mountContentScriptUiRoot(wrapper, options);
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
wrapper.remove();
|
||||
};
|
||||
|
||||
ctx.onInvalidated(remove);
|
||||
|
||||
return {
|
||||
iframe,
|
||||
wrapper,
|
||||
mount,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContentScriptIframe {
|
||||
/**
|
||||
* The iframe added to the DOM.
|
||||
*/
|
||||
iframe: HTMLIFrameElement;
|
||||
/**
|
||||
* A wrapper div that assists in positioning.
|
||||
*/
|
||||
wrapper: HTMLDivElement;
|
||||
/**
|
||||
* Function that mounts or remounts the UI on the page.
|
||||
*/
|
||||
mount: () => void;
|
||||
/**
|
||||
* Function that removes the UI from the webpage.
|
||||
*/
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
export type ContentScriptIframeOptions = ContentScriptPositioningOptions &
|
||||
ContentScriptAnchoredOptions & {
|
||||
/**
|
||||
* The path to the unlisted HTML file to display in the iframe.
|
||||
*/
|
||||
page: import('wxt/browser').PublicPath;
|
||||
};
|
||||
@@ -1,173 +0,0 @@
|
||||
import { createIsolatedElement } from '@webext-core/isolated-element';
|
||||
import { browser } from '~/browser';
|
||||
import { logger } from '~/client/utils/logger';
|
||||
import { ContentScriptContext } from './content-script-context';
|
||||
import {
|
||||
ContentScriptAnchoredOptions,
|
||||
ContentScriptPositioningOptions,
|
||||
applyContentScriptUiPosition,
|
||||
mountContentScriptUiRoot,
|
||||
} from '../utils/content-script-ui';
|
||||
|
||||
/**
|
||||
* Utility for mounting content script UI's with isolated styles. Automatically removed from the DOM
|
||||
* when the content script's context is invalidated.
|
||||
*
|
||||
* See https://wxt.dev/entrypoints/content-scripts.html#ui for full documentation.
|
||||
*
|
||||
* @example
|
||||
* // entrypoints/example-ui.content/index.ts
|
||||
* import "./style.css"
|
||||
*
|
||||
* export default defineContentScript({
|
||||
* matches: ["*://*.google.com/*"],
|
||||
* cssInjectionMode: "ui",
|
||||
*
|
||||
* async main(ctx) {
|
||||
* const ui = await createContentScriptUi(ctx, {
|
||||
* name: "example-overlay",
|
||||
* type: "modal",
|
||||
* mount(container) {
|
||||
* const app = document.createElement("div");
|
||||
* app.textContent = "Content Script UI";
|
||||
* container.append(app);
|
||||
* }
|
||||
* })
|
||||
* ui.mount();
|
||||
* }
|
||||
* })
|
||||
*/
|
||||
export async function createContentScriptUi<TApp>(
|
||||
ctx: ContentScriptContext,
|
||||
options: ContentScriptUiOptions<TApp>,
|
||||
): Promise<ContentScriptUi<TApp>> {
|
||||
const css = [options.css ?? ''];
|
||||
if (ctx.options?.cssInjectionMode === 'ui') {
|
||||
css.push(await loadCss());
|
||||
}
|
||||
|
||||
const {
|
||||
isolatedElement: uiContainer,
|
||||
parentElement: shadowHost,
|
||||
shadow,
|
||||
} = await createIsolatedElement({
|
||||
name: options.name,
|
||||
css: {
|
||||
textContent: css.join('\n').trim(),
|
||||
},
|
||||
mode: 'open',
|
||||
});
|
||||
|
||||
let mounted: TApp;
|
||||
|
||||
const mount = () => {
|
||||
// Mount UI inside shadow root
|
||||
mounted = options.mount(uiContainer);
|
||||
|
||||
// Add shadow root element to DOM
|
||||
mountContentScriptUiRoot(shadowHost, options);
|
||||
applyContentScriptUiPosition(
|
||||
shadowHost,
|
||||
shadow.querySelector('html'),
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
// Detatch shadow root from DOM
|
||||
shadowHost.remove();
|
||||
// Cleanup mounted state
|
||||
options.onRemove?.(mounted);
|
||||
// Remove children from uiContainer
|
||||
while (uiContainer.lastChild)
|
||||
uiContainer.removeChild(uiContainer.lastChild);
|
||||
};
|
||||
|
||||
ctx.onInvalidated(remove);
|
||||
|
||||
return {
|
||||
shadow,
|
||||
shadowHost,
|
||||
uiContainer,
|
||||
mount,
|
||||
remove,
|
||||
mounted: mounted!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the CSS for the current entrypoint.
|
||||
*/
|
||||
async function loadCss(): Promise<string> {
|
||||
const url = browser.runtime.getURL(`/content-scripts/${__ENTRYPOINT__}.css`);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const css = await res.text();
|
||||
|
||||
// Replace :root selectors with :host since we're in a shadow root
|
||||
return css.replaceAll(':root', ':host');
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Failed to load styles @ ${url}. Did you forget to import the stylesheet in your entrypoint?`,
|
||||
err,
|
||||
);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ContentScriptUi<TApp> {
|
||||
/**
|
||||
* The `HTMLElement` hosting the shadow root used to isolate the UI's styles. This is the element
|
||||
* that get's added to the DOM. This element's style is not isolated from the webpage.
|
||||
*/
|
||||
shadowHost: HTMLElement;
|
||||
/**
|
||||
* The container element inside the `ShadowRoot` whose styles are isolated. The UI is mounted
|
||||
* inside this `HTMLElement`.
|
||||
*/
|
||||
uiContainer: HTMLElement;
|
||||
/**
|
||||
* The shadow root performing the isolation.
|
||||
*/
|
||||
shadow: ShadowRoot;
|
||||
/**
|
||||
* Custom data returned from the `options.mount` function.
|
||||
*/
|
||||
mounted: TApp;
|
||||
/**
|
||||
* Function that mounts or remounts the UI on the page.
|
||||
*/
|
||||
mount: () => void;
|
||||
/**
|
||||
* Function that removes the UI from the webpage.
|
||||
*/
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
export type ContentScriptUiOptions<TApp> = ContentScriptPositioningOptions &
|
||||
ContentScriptAnchoredOptions & {
|
||||
/**
|
||||
* The name of the custom component used to host the ShadowRoot. Must be kebab-case.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Callback executed when mounting the UI. This function should create and append the UI to the
|
||||
* `container` element. It is called every time `ui.mount()` is called
|
||||
*
|
||||
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
|
||||
*/
|
||||
mount: (container: Element) => TApp;
|
||||
/**
|
||||
* Callback called when the UI is removed from the webpage. Use to cleanup your UI, like
|
||||
* unmounting your vue or react apps.
|
||||
*/
|
||||
onRemove?: (mounted: TApp) => void;
|
||||
/**
|
||||
* Custom CSS text to apply to the UI. If your content script imports/generates CSS and you've
|
||||
* set `cssInjectionMode: "ui"`, the imported CSS will be included automatically. You do not need
|
||||
* to pass those styles in here. This is for any additional styles not in the imported CSS.
|
||||
*
|
||||
* See https://wxt.dev/entrypoints/content-scripts.html#ui for more info.
|
||||
*/
|
||||
css?: string;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { browser } from '~/browser';
|
||||
|
||||
export class WxtLocationChangeEvent extends Event {
|
||||
static EVENT_NAME = getUniqueEventName('wxt:locationchange');
|
||||
|
||||
constructor(
|
||||
readonly newUrl: URL,
|
||||
readonly oldUrl: URL,
|
||||
) {
|
||||
super(WxtLocationChangeEvent.EVENT_NAME, {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an event name unique to the extension and content script that's running.
|
||||
*/
|
||||
export function getUniqueEventName(eventName: string): string {
|
||||
// During the build process, import.meta.env is not defined when importing
|
||||
// entrypoints to get their metadata.
|
||||
const entrypointName =
|
||||
typeof import.meta.env === 'undefined'
|
||||
? 'build'
|
||||
: import.meta.env.ENTRYPOINT;
|
||||
|
||||
return `${browser.runtime.id}:${entrypointName}:${eventName}`;
|
||||
}
|
||||
@@ -1,4 +1,2 @@
|
||||
export * from './content-script-context';
|
||||
export * from './content-script-ui';
|
||||
export * from './content-script-iframe';
|
||||
export * from './define-content-script';
|
||||
export * from './ui';
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ContentScriptContext } from '.';
|
||||
import { WxtLocationChangeEvent } from './custom-events';
|
||||
|
||||
/**
|
||||
* Create a util that watches for URL changes, dispatching the custom event when detected. Stops
|
||||
* watching when content script is invalidated.
|
||||
*/
|
||||
export function createLocationWatcher(ctx: ContentScriptContext) {
|
||||
let interval: number | undefined;
|
||||
let oldUrl: URL;
|
||||
|
||||
return {
|
||||
/**
|
||||
* Ensure the location watcher is actively looking for URL changes. If it's already watching,
|
||||
* this is a noop.
|
||||
*/
|
||||
run() {
|
||||
if (interval != null) return;
|
||||
|
||||
oldUrl = new URL(location.href);
|
||||
interval = ctx.setInterval(() => {
|
||||
let newUrl = new URL(location.href);
|
||||
if (newUrl.href !== oldUrl.href) {
|
||||
window.dispatchEvent(new WxtLocationChangeEvent(newUrl, oldUrl));
|
||||
oldUrl = newUrl;
|
||||
}
|
||||
}, 1e3);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { describe, it, beforeEach, vi, expect } from 'vitest';
|
||||
import { createIntegratedUi, createIframeUi, createShadowRootUi } from '..';
|
||||
import { ContentScriptContext } from '../../content-script-context';
|
||||
|
||||
function appendTestApp(container: HTMLElement) {
|
||||
container.innerHTML = '<app>Hello world</app>';
|
||||
}
|
||||
|
||||
const fetch = vi.fn();
|
||||
|
||||
describe('Content Script UIs', () => {
|
||||
let ctx: ContentScriptContext;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="parent">
|
||||
<p id="one">one</p>
|
||||
<p id="two">two</p>
|
||||
<p id="three"></p>
|
||||
</div>
|
||||
`;
|
||||
window.fetch = fetch;
|
||||
fetch.mockResolvedValue({ text: () => Promise.resolve('') });
|
||||
ctx = new ContentScriptContext('test');
|
||||
});
|
||||
|
||||
describe('type', () => {
|
||||
describe('integrated', () => {
|
||||
it('should add a wrapper and custom UI to the page', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
expect(document.querySelector('app')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should allow customizing the wrapper tag', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
tag: 'pre',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('pre[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
expect(document.querySelector('app')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('iframe', () => {
|
||||
it('should add a wrapper and iframe to the page', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
page: '/page.html',
|
||||
position: 'inline',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('div[data-wxt-iframe]')).toBeDefined();
|
||||
expect(document.querySelector('iframe')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shadow-root', () => {
|
||||
it('should load a shadow root to the page', async () => {
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
position: 'inline',
|
||||
name: 'test',
|
||||
onMount(uiContainer) {
|
||||
appendTestApp(uiContainer);
|
||||
},
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('test[data-wxt-shadow-root]'),
|
||||
).not.toBeNull();
|
||||
expect(ui.shadow.querySelector('app')).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['open', 'open'],
|
||||
[undefined, 'open'],
|
||||
['closed', 'closed'],
|
||||
] as const)(
|
||||
'should respect the shadow root mode (%s -> %s)',
|
||||
async (input, expected) => {
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
position: 'inline',
|
||||
name: 'test',
|
||||
mode: input,
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
|
||||
expect(ui.shadow.mode).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('position', () => {
|
||||
describe('inline', () => {
|
||||
it('should wrap the UI in a simple div', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'inline',
|
||||
page: '/page.html',
|
||||
});
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe=""><iframe src="chrome-extension://test-extension-id/page.html"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('overlay', () => {
|
||||
it('should wrap the UI in a positioned div when alignment=undefined', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; top: 0px; left: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should wrap the UI in a positioned div when alignment=top-left', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
alignment: 'top-left',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; top: 0px; left: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should wrap the UI in a positioned div when alignment=top-right', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
alignment: 'top-right',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; top: 0px; right: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should wrap the UI in a positioned div when alignment=bottom-right', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
alignment: 'bottom-right',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; bottom: 0px; right: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should wrap the UI in a positioned div when alignment=bottom-left', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
alignment: 'bottom-left',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; bottom: 0px; left: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect the provided zIndex', () => {
|
||||
const zIndex = 123;
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
zIndex,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.style.zIndex).toBe(String(zIndex));
|
||||
});
|
||||
});
|
||||
|
||||
describe('modal', () => {
|
||||
it('should wrap the UI in a div with a fixed position', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'modal',
|
||||
page: '/page.html',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: fixed; top: 0px; bottom: 0px; left: 0px; right: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect the provided zIndex', () => {
|
||||
const zIndex = 123;
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'modal',
|
||||
page: '/page.html',
|
||||
zIndex,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.style.zIndex).toBe(String(zIndex));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('anchor', () => {
|
||||
describe('undefined', () => {
|
||||
it('should append the element to the body', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('body > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('string', () => {
|
||||
it('should append the element using the specified query selector', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
anchor: '#parent',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Element', () => {
|
||||
it('should append the element using the specified element', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
anchor: document.getElementById('parent'),
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('function', () => {
|
||||
it('should append the element using the specified function', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
anchor: () => document.getElementById('parent'),
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error when the anchor does not exist', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
anchor: () => document.getElementById('i-do-not-exist'),
|
||||
});
|
||||
|
||||
expect(ui.mount).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('append', () => {
|
||||
describe.each([undefined, 'last'] as const)('%s', (append) => {
|
||||
it('should append the element as the last child of the anchor', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#parent',
|
||||
append,
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'#parent > div[data-wxt-integrated]:last-child',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('first', () => {
|
||||
it('should append the element as the last child of the anchor', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#parent',
|
||||
append: 'first',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'#parent > div[data-wxt-integrated]:first-child',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('replace', () => {
|
||||
it('should replace the the anchor', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#parent',
|
||||
append: 'replace',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('body > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
expect(document.querySelector('#parent')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('before', () => {
|
||||
it('should append the UI before the anchor', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#one',
|
||||
append: 'before',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'#parent > div[data-wxt-integrated]:first-child',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('after', () => {
|
||||
it('should append the UI after the anchor', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#three',
|
||||
append: 'after',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'#parent > div[data-wxt-integrated]:last-child',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('function', () => {
|
||||
it('should append the UI using a function', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#parent',
|
||||
append: (anchor, ui) => {
|
||||
anchor.replaceWith(ui);
|
||||
},
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('body > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
expect(document.querySelector('#parent')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { browser } from '~/browser';
|
||||
import { ContentScriptContext } from '..';
|
||||
import {
|
||||
ContentScriptAnchoredOptions,
|
||||
ContentScriptPositioningOptions,
|
||||
IframeContentScriptUi,
|
||||
IframeContentScriptUiOptions,
|
||||
IntegratedContentScriptUi,
|
||||
IntegratedContentScriptUiOptions,
|
||||
ShadowRootContentScriptUi,
|
||||
ShadowRootContentScriptUiOptions,
|
||||
} from './types';
|
||||
import { logger } from '~/sandbox/utils/logger';
|
||||
import { createIsolatedElement } from '@webext-core/isolated-element';
|
||||
export * from './types';
|
||||
|
||||
/**
|
||||
* Create a content script UI without any isolation.
|
||||
*
|
||||
* @see https://wxt.dev/guide/content-script-ui.html#integrated
|
||||
*/
|
||||
export function createIntegratedUi<TMounted>(
|
||||
ctx: ContentScriptContext,
|
||||
options: IntegratedContentScriptUiOptions<TMounted>,
|
||||
): IntegratedContentScriptUi<TMounted> {
|
||||
const wrapper = document.createElement(options.tag || 'div');
|
||||
wrapper.setAttribute('data-wxt-integrated', '');
|
||||
|
||||
let mounted: TMounted | undefined = undefined;
|
||||
const mount = () => {
|
||||
applyPosition(wrapper, undefined, options);
|
||||
mountUi(wrapper, options);
|
||||
mounted = options.onMount?.(wrapper);
|
||||
};
|
||||
const remove = () => {
|
||||
options.onRemove?.(mounted);
|
||||
wrapper.remove();
|
||||
};
|
||||
|
||||
ctx.onInvalidated(remove);
|
||||
|
||||
return {
|
||||
mounted,
|
||||
wrapper,
|
||||
mount,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a content script UI using an iframe.
|
||||
*
|
||||
* @see https://wxt.dev/guide/content-script-ui.html#iframe
|
||||
*/
|
||||
export function createIframeUi<TMounted>(
|
||||
ctx: ContentScriptContext,
|
||||
options: IframeContentScriptUiOptions<TMounted>,
|
||||
): IframeContentScriptUi<TMounted> {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.setAttribute('data-wxt-iframe', '');
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = browser.runtime.getURL(options.page);
|
||||
wrapper.appendChild(iframe);
|
||||
|
||||
let mounted: TMounted | undefined = undefined;
|
||||
const mount = () => {
|
||||
applyPosition(wrapper, iframe, options);
|
||||
mountUi(wrapper, options);
|
||||
mounted = options.onMount?.(wrapper, iframe);
|
||||
};
|
||||
const remove = () => {
|
||||
options.onRemove?.(mounted);
|
||||
wrapper.remove();
|
||||
};
|
||||
|
||||
ctx.onInvalidated(remove);
|
||||
|
||||
return {
|
||||
mounted,
|
||||
iframe,
|
||||
wrapper,
|
||||
mount,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a content script UI inside a [`ShadowRoot`](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot).
|
||||
*
|
||||
* > This function is async because it has to load the CSS via a network call.
|
||||
*
|
||||
* @see https://wxt.dev/guide/content-script-ui.html#shadowroot
|
||||
*/
|
||||
export async function createShadowRootUi<TMounted>(
|
||||
ctx: ContentScriptContext,
|
||||
options: ShadowRootContentScriptUiOptions<TMounted>,
|
||||
): Promise<ShadowRootContentScriptUi<TMounted>> {
|
||||
const css = [options.css ?? ''];
|
||||
if (ctx.options?.cssInjectionMode === 'ui') {
|
||||
const entryCss = await loadCss();
|
||||
// Replace :root selectors with :host since we're in a shadow root
|
||||
css.push(entryCss.replaceAll(':root', ':host'));
|
||||
}
|
||||
|
||||
const {
|
||||
isolatedElement: uiContainer,
|
||||
parentElement: shadowHost,
|
||||
shadow,
|
||||
} = await createIsolatedElement({
|
||||
name: options.name,
|
||||
css: {
|
||||
textContent: css.join('\n').trim(),
|
||||
},
|
||||
mode: options.mode ?? 'open',
|
||||
isolateEvents: options.isolateEvents,
|
||||
});
|
||||
shadowHost.setAttribute('data-wxt-shadow-root', '');
|
||||
|
||||
let mounted: TMounted;
|
||||
|
||||
const mount = () => {
|
||||
// Mount UI inside shadow root
|
||||
mounted = options.onMount(uiContainer, shadow, shadowHost);
|
||||
|
||||
// Add shadow root element to DOM
|
||||
mountUi(shadowHost, options);
|
||||
applyPosition(shadowHost, shadow.querySelector('html'), options);
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
// Detatch shadow root from DOM
|
||||
shadowHost.remove();
|
||||
// Cleanup mounted state
|
||||
options.onRemove?.(mounted);
|
||||
// Remove children from uiContainer
|
||||
while (uiContainer.lastChild)
|
||||
uiContainer.removeChild(uiContainer.lastChild);
|
||||
};
|
||||
|
||||
ctx.onInvalidated(remove);
|
||||
|
||||
return {
|
||||
shadow,
|
||||
shadowHost,
|
||||
uiContainer,
|
||||
mount,
|
||||
remove,
|
||||
mounted: mounted!,
|
||||
};
|
||||
}
|
||||
|
||||
function applyPosition(
|
||||
root: HTMLElement,
|
||||
positionedElement: HTMLElement | undefined | null,
|
||||
options: ContentScriptPositioningOptions,
|
||||
): void {
|
||||
// No positioning for inline UIs
|
||||
if (options.position === 'inline') return;
|
||||
|
||||
if (options.zIndex != null) root.style.zIndex = String(options.zIndex);
|
||||
|
||||
root.style.overflow = 'visible';
|
||||
root.style.position = 'relative';
|
||||
root.style.width = '0';
|
||||
root.style.height = '0';
|
||||
root.style.display = 'block';
|
||||
|
||||
if (positionedElement) {
|
||||
if (options.position === 'overlay') {
|
||||
positionedElement.style.position = 'absolute';
|
||||
if (options.alignment?.startsWith('bottom-'))
|
||||
positionedElement.style.bottom = '0';
|
||||
else positionedElement.style.top = '0';
|
||||
|
||||
if (options.alignment?.endsWith('-right'))
|
||||
positionedElement.style.right = '0';
|
||||
else positionedElement.style.left = '0';
|
||||
} else {
|
||||
positionedElement.style.position = 'fixed';
|
||||
positionedElement.style.top = '0';
|
||||
positionedElement.style.bottom = '0';
|
||||
positionedElement.style.left = '0';
|
||||
positionedElement.style.right = '0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAnchor(options: ContentScriptAnchoredOptions): Element | undefined {
|
||||
if (options.anchor == null) return document.body;
|
||||
|
||||
let resolved =
|
||||
typeof options.anchor === 'function' ? options.anchor() : options.anchor;
|
||||
if (typeof resolved === 'string')
|
||||
return document.querySelector<Element>(resolved) ?? undefined;
|
||||
return resolved ?? undefined;
|
||||
}
|
||||
|
||||
function mountUi(
|
||||
root: HTMLElement,
|
||||
options: ContentScriptAnchoredOptions,
|
||||
): void {
|
||||
const anchor = getAnchor(options);
|
||||
if (anchor == null)
|
||||
throw Error(
|
||||
'Failed to mount content script UI: could not find anchor element',
|
||||
);
|
||||
|
||||
switch (options.append) {
|
||||
case undefined:
|
||||
case 'last':
|
||||
anchor.append(root);
|
||||
break;
|
||||
case 'first':
|
||||
anchor.prepend(root);
|
||||
break;
|
||||
case 'replace':
|
||||
anchor.replaceWith(root);
|
||||
break;
|
||||
case 'after':
|
||||
anchor.parentElement?.insertBefore(root, anchor.nextElementSibling);
|
||||
break;
|
||||
case 'before':
|
||||
anchor.parentElement?.insertBefore(root, anchor);
|
||||
break;
|
||||
default:
|
||||
options.append(anchor, root);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the CSS for the current entrypoint.
|
||||
*/
|
||||
async function loadCss(): Promise<string> {
|
||||
const url = browser.runtime.getURL(
|
||||
`/content-scripts/${import.meta.env.ENTRYPOINT}.css`,
|
||||
);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
return await res.text();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Failed to load styles @ ${url}. Did you forget to import the stylesheet in your entrypoint?`,
|
||||
err,
|
||||
);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
export interface IntegratedContentScriptUi<TMounted>
|
||||
extends ContentScriptUi<TMounted> {
|
||||
/**
|
||||
* A wrapper div that assists in positioning.
|
||||
*/
|
||||
wrapper: HTMLElement;
|
||||
}
|
||||
|
||||
export interface IframeContentScriptUi<TMounted>
|
||||
extends ContentScriptUi<TMounted> {
|
||||
/**
|
||||
* The iframe added to the DOM.
|
||||
*/
|
||||
iframe: HTMLIFrameElement;
|
||||
/**
|
||||
* A wrapper div that assists in positioning.
|
||||
*/
|
||||
wrapper: HTMLDivElement;
|
||||
}
|
||||
|
||||
export interface ShadowRootContentScriptUi<TMounted>
|
||||
extends ContentScriptUi<TMounted> {
|
||||
/**
|
||||
* The `HTMLElement` hosting the shadow root used to isolate the UI's styles. This is the element
|
||||
* that get's added to the DOM. This element's style is not isolated from the webpage.
|
||||
*/
|
||||
shadowHost: HTMLElement;
|
||||
/**
|
||||
* The container element inside the `ShadowRoot` whose styles are isolated. The UI is mounted
|
||||
* inside this `HTMLElement`.
|
||||
*/
|
||||
uiContainer: HTMLElement;
|
||||
/**
|
||||
* The shadow root performing the isolation.
|
||||
*/
|
||||
shadow: ShadowRoot;
|
||||
}
|
||||
|
||||
export interface ContentScriptUi<TMounted> {
|
||||
/**
|
||||
* Function that mounts or remounts the UI on the page.
|
||||
*/
|
||||
mount: () => void;
|
||||
/**
|
||||
* Function that removes the UI from the webpage.
|
||||
*/
|
||||
remove: () => void;
|
||||
/**>
|
||||
* Custom data returned from the `options.mount` function.
|
||||
*/
|
||||
mounted: TMounted | undefined;
|
||||
}
|
||||
|
||||
export type ContentScriptUiOptions<TMounted> = ContentScriptPositioningOptions &
|
||||
ContentScriptAnchoredOptions & {
|
||||
/**
|
||||
* Callback called before the UI is removed from the webpage. Use to cleanup your UI, like
|
||||
* unmounting your Vue or React apps.
|
||||
*/
|
||||
onRemove?: (mounted: TMounted | undefined) => void;
|
||||
};
|
||||
|
||||
export type IntegratedContentScriptUiOptions<TMounted> =
|
||||
ContentScriptUiOptions<TMounted> & {
|
||||
/**
|
||||
* Tag used to create the wrapper element.
|
||||
*
|
||||
* @default "div"
|
||||
*/
|
||||
tag?: string;
|
||||
/**
|
||||
* Callback executed when mounting the UI. This function should create and append the UI to the
|
||||
* `wrapper` element. It is called every time `ui.mount()` is called.
|
||||
*
|
||||
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
|
||||
*/
|
||||
onMount: (wrapper: HTMLElement) => TMounted;
|
||||
};
|
||||
|
||||
export type IframeContentScriptUiOptions<TMounted> =
|
||||
ContentScriptUiOptions<TMounted> & {
|
||||
/**
|
||||
* The path to the HTML page that will be shown in the iframe. This string is passed into
|
||||
* `browser.runtime.getURL`.
|
||||
*/
|
||||
page: PublicPath;
|
||||
/**
|
||||
* Callback executed when mounting the UI. Use this function to customize the iframe or wrapper
|
||||
* element's appearance. It is called every time `ui.mount()` is called.
|
||||
*
|
||||
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
|
||||
*/
|
||||
onMount?: (wrapper: HTMLElement, iframe: HTMLIFrameElement) => TMounted;
|
||||
};
|
||||
|
||||
export type ShadowRootContentScriptUiOptions<TMounted> =
|
||||
ContentScriptUiOptions<TMounted> & {
|
||||
/**
|
||||
* The name of the custom component used to host the ShadowRoot. Must be kebab-case.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Custom CSS text to apply to the UI. If your content script imports/generates CSS and you've
|
||||
* set `cssInjectionMode: "ui"`, the imported CSS will be included automatically. You do not need
|
||||
* to pass those styles in here. This is for any additional styles not in the imported CSS.
|
||||
*/
|
||||
css?: string;
|
||||
/**
|
||||
* ShadowRoot's mode.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/mode
|
||||
* @default "open"
|
||||
*/
|
||||
mode?: 'open' | 'closed';
|
||||
/**
|
||||
* When enabled, `event.stopPropagation` will be called on events trying to bubble out of the
|
||||
* shadow root.
|
||||
*
|
||||
* - Set to `true` to stop the propagation of a default set of events,
|
||||
* `["keyup", "keydown", "keypress"]`
|
||||
* - Set to an array of event names to stop the propagation of a custom list of events
|
||||
*/
|
||||
isolateEvents?: boolean | string[];
|
||||
/**
|
||||
* Callback executed when mounting the UI. This function should create and append the UI to the
|
||||
* `uiContainer` element. It is called every time `ui.mount()` is called.
|
||||
*
|
||||
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
|
||||
*/
|
||||
onMount: (
|
||||
uiContainer: HTMLElement,
|
||||
shadow: ShadowRoot,
|
||||
shadowHost: HTMLElement,
|
||||
) => TMounted;
|
||||
};
|
||||
|
||||
export type ContentScriptOverlayAlignment =
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right';
|
||||
|
||||
/**
|
||||
* 
|
||||
*/
|
||||
export type ContentScriptAppendMode =
|
||||
| 'last'
|
||||
| 'first'
|
||||
| 'replace'
|
||||
| 'before'
|
||||
| 'after'
|
||||
| ((anchor: Element, ui: Element) => void);
|
||||
|
||||
export interface ContentScriptInlinePositioningOptions {
|
||||
position: 'inline';
|
||||
}
|
||||
|
||||
export interface ContentScriptOverlayPositioningOptions {
|
||||
position: 'overlay';
|
||||
/**
|
||||
* The `z-index` used on the `wrapper` element. Set to a positive number to show your UI over website
|
||||
* content.
|
||||
*/
|
||||
zIndex?: number;
|
||||
/**
|
||||
* When using `type: "overlay"`, the mounted element is 0px by 0px in size. Alignment specifies
|
||||
* which corner is aligned with that 0x0 pixel space.
|
||||
*
|
||||
* 
|
||||
*
|
||||
* @default "top-left"
|
||||
*/
|
||||
alignment?: ContentScriptOverlayAlignment;
|
||||
}
|
||||
|
||||
export interface ContentScriptModalPositioningOptions {
|
||||
position: 'modal';
|
||||
/**
|
||||
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
|
||||
* content.
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose between `"inline"`, `"overlay"`, or `"modal" `types.
|
||||
*
|
||||
* 
|
||||
*/
|
||||
export type ContentScriptPositioningOptions =
|
||||
| ContentScriptInlinePositioningOptions
|
||||
| ContentScriptOverlayPositioningOptions
|
||||
| ContentScriptModalPositioningOptions;
|
||||
|
||||
export interface ContentScriptAnchoredOptions {
|
||||
/**
|
||||
* A CSS selector, element, or function that returns one of the two. Along with `append`, the
|
||||
* `anchor` dictates where in the page the UI will be added.
|
||||
*/
|
||||
anchor?:
|
||||
| string
|
||||
| Element
|
||||
| null
|
||||
| undefined
|
||||
| (() => string | Element | null | undefined);
|
||||
/**
|
||||
* In combination with `anchor`, decide how to add the UI to the DOM.
|
||||
*
|
||||
* - `"last"` (default) - Add the UI as the last child of the `anchor` element
|
||||
* - `"first"` - Add the UI as the last child of the `anchor` element
|
||||
* - `"replace"` - Replace the `anchor` element with the UI.
|
||||
* - `"before"` - Add the UI as the sibling before the `anchor` element
|
||||
* - `"after"` - Add the UI as the sibling after the `anchor` element
|
||||
* - `(anchor, ui) => void` - Customizable function that let's you add the UI to the DOM
|
||||
*/
|
||||
append?: ContentScriptAppendMode | ((anchor: Element, ui: Element) => void);
|
||||
}
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Any runtime APIs that use the web extension APIs.
|
||||
*
|
||||
* @module wxt/client
|
||||
*/
|
||||
export * from './define-background';
|
||||
export * from './content-scripts';
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
export type ContentScriptOverlayAlignment =
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right';
|
||||
|
||||
export type ContentScriptAppendMode =
|
||||
| 'last'
|
||||
| 'first'
|
||||
| 'replace'
|
||||
| 'before'
|
||||
| 'after'
|
||||
| ((anchor: Element, ui: Element) => void);
|
||||
|
||||
export function mountContentScriptUiRoot(
|
||||
root: HTMLElement,
|
||||
options: ContentScriptAnchoredOptions,
|
||||
): void {
|
||||
const anchor = getAnchor(options);
|
||||
if (anchor == null)
|
||||
throw Error(
|
||||
'Failed to mount content script UI: could not find anchor element',
|
||||
);
|
||||
|
||||
switch (options.append) {
|
||||
case undefined:
|
||||
case 'last':
|
||||
anchor.append(root);
|
||||
break;
|
||||
case 'first':
|
||||
if (anchor.firstChild) {
|
||||
anchor.insertBefore(root, anchor.firstChild);
|
||||
} else {
|
||||
anchor.append(root);
|
||||
}
|
||||
break;
|
||||
case 'replace':
|
||||
anchor.replaceWith(root);
|
||||
break;
|
||||
case 'after':
|
||||
anchor.replaceWith(anchor, root);
|
||||
break;
|
||||
case 'before':
|
||||
anchor.replaceWith(root, anchor);
|
||||
break;
|
||||
default:
|
||||
options.append(anchor, root);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export type ContentScriptPositioningOptions =
|
||||
| { type: 'inline' }
|
||||
| {
|
||||
type: 'overlay';
|
||||
/**
|
||||
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
|
||||
* content.
|
||||
*/
|
||||
zIndex?: number;
|
||||
/**
|
||||
* When using `type: "overlay"`, the mounted element is 0px by 0px in size. Alignment specifies
|
||||
* which corner is aligned with that 0x0 pixel space.
|
||||
*
|
||||
* @default "top-left"
|
||||
*/
|
||||
alignment?: ContentScriptOverlayAlignment;
|
||||
}
|
||||
| {
|
||||
type: 'modal';
|
||||
/**
|
||||
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
|
||||
* content.
|
||||
*/
|
||||
zIndex?: number;
|
||||
};
|
||||
|
||||
export function applyContentScriptUiPosition(
|
||||
root: HTMLElement,
|
||||
positionedElement: HTMLElement | undefined | null,
|
||||
options: ContentScriptPositioningOptions,
|
||||
): void {
|
||||
if (options.type !== 'inline') {
|
||||
if (options.zIndex != null) root.style.zIndex = String(options.zIndex);
|
||||
|
||||
root.style.overflow = 'visible';
|
||||
root.style.position = 'relative';
|
||||
root.style.width = '0';
|
||||
root.style.height = '0';
|
||||
root.style.display = 'block';
|
||||
|
||||
if (positionedElement) {
|
||||
if (options.type === 'overlay') {
|
||||
positionedElement.style.position = 'absolute';
|
||||
if (options.alignment?.startsWith('bottom-'))
|
||||
positionedElement.style.bottom = '0';
|
||||
else positionedElement.style.top = '0';
|
||||
|
||||
if (options.alignment?.endsWith('-right'))
|
||||
positionedElement.style.right = '0';
|
||||
else positionedElement.style.left = '0';
|
||||
} else {
|
||||
positionedElement.style.position = 'fixed';
|
||||
positionedElement.style.top = '0';
|
||||
positionedElement.style.bottom = '0';
|
||||
positionedElement.style.left = '0';
|
||||
positionedElement.style.right = '0';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAnchor(options: ContentScriptAnchoredOptions): Element | undefined {
|
||||
if (options.anchor == null) return document.body;
|
||||
|
||||
let resolved =
|
||||
typeof options.anchor === 'function' ? options.anchor() : options.anchor;
|
||||
if (typeof resolved === 'string')
|
||||
return document.querySelector<Element>(resolved) ?? undefined;
|
||||
return resolved ?? undefined;
|
||||
}
|
||||
|
||||
export interface ContentScriptAnchoredOptions {
|
||||
/**
|
||||
* A CSS selector, element, or function that returns one of the two. Along with `append`, the
|
||||
* `anchor` dictates where in the page the UI will be added.
|
||||
*/
|
||||
anchor?:
|
||||
| string
|
||||
| Element
|
||||
| null
|
||||
| undefined
|
||||
| (() => string | Element | null | undefined);
|
||||
/**
|
||||
* In combination with `anchor`, decide how to add the UI to the DOM.
|
||||
*
|
||||
* - `"last"` (default) - Add the UI as the last child of the `anchor` element
|
||||
* - `"first"` - Add the UI as the last child of the `anchor` element
|
||||
* - `"replace"` - Replace the `anchor` element with the UI.
|
||||
* - `"before"` - Add the UI as the sibling before the `anchor` element
|
||||
* - `"after"` - Add the UI as the sibling after the `anchor` element
|
||||
* - `(anchor, ui) => void` - Customizable function that let's you add the UI to the DOM
|
||||
*/
|
||||
append?: ContentScriptAppendMode | ((anchor: Element, ui: Element) => void);
|
||||
}
|
||||
+5
-3
@@ -1,5 +1,6 @@
|
||||
import { BuildOutput, InlineConfig } from '~/types';
|
||||
import { getInternalConfig, internalBuild } from './utils/building';
|
||||
import { internalBuild } from './utils/building';
|
||||
import { registerWxt } from './wxt';
|
||||
|
||||
/**
|
||||
* Bundles the extension for production. Returns a promise of the build result. Discovers the `wxt.config.ts` file in
|
||||
@@ -15,6 +16,7 @@ import { getInternalConfig, internalBuild } from './utils/building';
|
||||
* })
|
||||
*/
|
||||
export async function build(config?: InlineConfig): Promise<BuildOutput> {
|
||||
const internalConfig = await getInternalConfig(config ?? {}, 'build');
|
||||
return await internalBuild(internalConfig);
|
||||
await registerWxt('build', config);
|
||||
|
||||
return await internalBuild();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import type * as vite from 'vite';
|
||||
import {
|
||||
BuildStepOutput,
|
||||
Entrypoint,
|
||||
InlineConfig,
|
||||
ResolvedConfig,
|
||||
UserConfig,
|
||||
VirtualEntrypointType,
|
||||
WxtBuilder,
|
||||
WxtBuilderServer,
|
||||
} from '~/types';
|
||||
import * as wxtPlugins from './plugins';
|
||||
import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
|
||||
|
||||
export async function createViteBuilder(
|
||||
inlineConfig: InlineConfig,
|
||||
userConfig: UserConfig,
|
||||
wxtConfig: Omit<ResolvedConfig, 'builder'>,
|
||||
): Promise<WxtBuilder> {
|
||||
const vite = await import('vite');
|
||||
|
||||
/**
|
||||
* Returns the base vite config shared by all builds based on the inline and user config.
|
||||
*/
|
||||
const getBaseConfig = async () => {
|
||||
const resolvedInlineConfig =
|
||||
(await inlineConfig.vite?.(wxtConfig.env)) ?? {};
|
||||
const resolvedUserConfig = (await userConfig.vite?.(wxtConfig.env)) ?? {};
|
||||
|
||||
const config: vite.InlineConfig = vite.mergeConfig(
|
||||
resolvedUserConfig,
|
||||
resolvedInlineConfig,
|
||||
);
|
||||
|
||||
config.root = wxtConfig.root;
|
||||
config.configFile = false;
|
||||
config.logLevel = 'warn';
|
||||
config.mode = wxtConfig.mode;
|
||||
|
||||
config.build ??= {};
|
||||
config.build.outDir = wxtConfig.outDir;
|
||||
config.build.emptyOutDir = false;
|
||||
// Disable minification for the dev command
|
||||
if (config.build.minify == null && wxtConfig.command === 'serve') {
|
||||
config.build.minify = false;
|
||||
}
|
||||
// Enable inline sourcemaps for the dev command (so content scripts have sourcemaps)
|
||||
if (config.build.sourcemap == null && wxtConfig.command === 'serve') {
|
||||
config.build.sourcemap = 'inline';
|
||||
}
|
||||
|
||||
config.plugins ??= [];
|
||||
config.plugins.push(
|
||||
wxtPlugins.download(wxtConfig),
|
||||
wxtPlugins.devHtmlPrerender(wxtConfig),
|
||||
wxtPlugins.unimport(wxtConfig),
|
||||
wxtPlugins.virtualEntrypoint('background', wxtConfig),
|
||||
wxtPlugins.virtualEntrypoint('content-script-isolated-world', wxtConfig),
|
||||
wxtPlugins.virtualEntrypoint('content-script-main-world', wxtConfig),
|
||||
wxtPlugins.virtualEntrypoint('unlisted-script', wxtConfig),
|
||||
wxtPlugins.devServerGlobals(wxtConfig),
|
||||
wxtPlugins.tsconfigPaths(wxtConfig),
|
||||
wxtPlugins.noopBackground(),
|
||||
wxtPlugins.globals(wxtConfig),
|
||||
wxtPlugins.excludeBrowserPolyfill(wxtConfig),
|
||||
wxtPlugins.defineImportMeta(),
|
||||
);
|
||||
if (wxtConfig.analysis.enabled) {
|
||||
config.plugins.push(wxtPlugins.bundleAnalysis(wxtConfig));
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the basic config for building an entrypoint in [lib mode](https://vitejs.dev/guide/build.html#library-mode).
|
||||
*/
|
||||
const getLibModeConfig = (entrypoint: Entrypoint): vite.InlineConfig => {
|
||||
const entry = getRollupEntry(entrypoint);
|
||||
const plugins: NonNullable<vite.UserConfig['plugins']> = [
|
||||
wxtPlugins.entrypointGroupGlobals(entrypoint),
|
||||
];
|
||||
if (
|
||||
entrypoint.type === 'content-script-style' ||
|
||||
entrypoint.type === 'unlisted-style'
|
||||
) {
|
||||
plugins.push(wxtPlugins.cssEntrypoints(entrypoint, wxtConfig));
|
||||
}
|
||||
|
||||
const libMode: vite.UserConfig = {
|
||||
mode: wxtConfig.mode,
|
||||
plugins,
|
||||
build: {
|
||||
lib: {
|
||||
entry,
|
||||
formats: ['iife'],
|
||||
name: '_',
|
||||
fileName: entrypoint.name,
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// There's only a single output for this build, so we use the desired bundle path for the
|
||||
// entry output (like "content-scripts/overlay.js")
|
||||
entryFileNames: getEntrypointBundlePath(
|
||||
entrypoint,
|
||||
wxtConfig.outDir,
|
||||
'.js',
|
||||
),
|
||||
// Output content script CSS to `content-scripts/`, but all other scripts are written to
|
||||
// `assets/`.
|
||||
assetFileNames: ({ name }) => {
|
||||
if (
|
||||
entrypoint.type === 'content-script' &&
|
||||
name?.endsWith('css')
|
||||
) {
|
||||
return `content-scripts/${entrypoint.name}.[ext]`;
|
||||
} else {
|
||||
return `assets/${entrypoint.name}.[ext]`;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
// See https://github.com/aklinker1/vite-plugin-web-extension/issues/96
|
||||
'process.env.NODE_ENV': JSON.stringify(wxtConfig.mode),
|
||||
},
|
||||
};
|
||||
return libMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the basic config for building multiple entrypoints in [multi-page mode](https://vitejs.dev/guide/build.html#multi-page-app).
|
||||
*/
|
||||
const getMultiPageConfig = (entrypoints: Entrypoint[]): vite.InlineConfig => {
|
||||
return {
|
||||
mode: wxtConfig.mode,
|
||||
plugins: [
|
||||
wxtPlugins.multipageMove(entrypoints, wxtConfig),
|
||||
wxtPlugins.entrypointGroupGlobals(entrypoints),
|
||||
],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: entrypoints.reduce<Record<string, string>>((input, entry) => {
|
||||
input[entry.name] = getRollupEntry(entry);
|
||||
return input;
|
||||
}, {}),
|
||||
output: {
|
||||
// Include a hash to prevent conflicts
|
||||
chunkFileNames: 'chunks/[name]-[hash].js',
|
||||
// Place JS entrypoints in main directory without a hash. (popup.html & popup.js are
|
||||
// next to each other). The unique entrypoint name requirement prevents conflicts with
|
||||
// scripts of the same name
|
||||
entryFileNames: '[name].js',
|
||||
// We can't control the "name", so we need a hash to prevent conflicts
|
||||
assetFileNames: 'assets/[name]-[hash].[ext]',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the basic config for building a sinlge CSS entrypoint in [multi-page mode](https://vitejs.dev/guide/build.html#multi-page-app).
|
||||
*/
|
||||
const getCssConfig = (entrypoint: Entrypoint): vite.InlineConfig => {
|
||||
return {
|
||||
mode: wxtConfig.mode,
|
||||
plugins: [wxtPlugins.entrypointGroupGlobals(entrypoint)],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
[entrypoint.name]: entrypoint.inputPath,
|
||||
},
|
||||
output: {
|
||||
assetFileNames: () => {
|
||||
if (entrypoint.type === 'content-script-style') {
|
||||
return `content-scripts/${entrypoint.name}.[ext]`;
|
||||
} else {
|
||||
return `assets/${entrypoint.name}.[ext]`;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'Vite',
|
||||
version: vite.version,
|
||||
async build(group) {
|
||||
let entryConfig;
|
||||
if (Array.isArray(group)) entryConfig = getMultiPageConfig(group);
|
||||
else if (group.inputPath.endsWith('.css'))
|
||||
entryConfig = getCssConfig(group);
|
||||
else entryConfig = getLibModeConfig(group);
|
||||
|
||||
const buildConfig = vite.mergeConfig(await getBaseConfig(), entryConfig);
|
||||
const result = await vite.build(buildConfig);
|
||||
return {
|
||||
entrypoints: group,
|
||||
chunks: getBuildOutputChunks(result),
|
||||
};
|
||||
},
|
||||
async createServer(info) {
|
||||
const serverConfig: vite.InlineConfig = {
|
||||
server: {
|
||||
port: info.port,
|
||||
strictPort: true,
|
||||
host: info.hostname,
|
||||
origin: info.origin,
|
||||
},
|
||||
};
|
||||
const baseConfig = await getBaseConfig();
|
||||
const viteServer = await vite.createServer(
|
||||
vite.mergeConfig(baseConfig, serverConfig),
|
||||
);
|
||||
|
||||
const server: WxtBuilderServer = {
|
||||
async listen() {
|
||||
await viteServer.listen(info.port);
|
||||
},
|
||||
async close() {
|
||||
await viteServer.close();
|
||||
},
|
||||
transformHtml(...args) {
|
||||
return viteServer.transformIndexHtml(...args);
|
||||
},
|
||||
ws: {
|
||||
send(message, payload) {
|
||||
return viteServer.ws.send(message, payload);
|
||||
},
|
||||
on(message, cb) {
|
||||
viteServer.ws.on(message, cb);
|
||||
},
|
||||
},
|
||||
watcher: viteServer.watcher,
|
||||
};
|
||||
|
||||
return server;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getBuildOutputChunks(
|
||||
result: Awaited<ReturnType<typeof vite.build>>,
|
||||
): BuildStepOutput['chunks'] {
|
||||
if ('on' in result) throw Error('wxt does not support vite watch mode.');
|
||||
if (Array.isArray(result)) return result.flatMap(({ output }) => output);
|
||||
return result.output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the input module ID (virtual or real file) for an entrypoint. The returned string should
|
||||
* be passed as an input to rollup.
|
||||
*/
|
||||
function getRollupEntry(entrypoint: Entrypoint): string {
|
||||
let virtualEntrypointType: VirtualEntrypointType | undefined;
|
||||
switch (entrypoint.type) {
|
||||
case 'background':
|
||||
case 'unlisted-script':
|
||||
virtualEntrypointType = entrypoint.type;
|
||||
break;
|
||||
case 'content-script':
|
||||
virtualEntrypointType =
|
||||
entrypoint.options.world === 'MAIN'
|
||||
? 'content-script-main-world'
|
||||
: 'content-script-isolated-world';
|
||||
break;
|
||||
}
|
||||
return virtualEntrypointType
|
||||
? `virtual:wxt-${virtualEntrypointType}?${entrypoint.inputPath}`
|
||||
: entrypoint.inputPath;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Window } from 'happy-dom';
|
||||
import { pointToDevServer } from '../devHtmlPrerender';
|
||||
import {
|
||||
fakeDevServer,
|
||||
fakeResolvedConfig,
|
||||
} from '~/core/utils/testing/fake-objects';
|
||||
import { normalizePath } from '~/core/utils/paths';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('Dev HTML Prerender Plugin', () => {
|
||||
describe('pointToDevServer', () => {
|
||||
it.each([
|
||||
// File paths should be resolved
|
||||
['style.css', 'http://localhost:5173/entrypoints/popup/style.css'],
|
||||
['./style.css', 'http://localhost:5173/entrypoints/popup/style.css'],
|
||||
['../style.css', 'http://localhost:5173/entrypoints/style.css'],
|
||||
['~/assets/style.css', 'http://localhost:5173/assets/style.css'],
|
||||
['~~/assets/style.css', 'http://localhost:5173/assets/style.css'],
|
||||
['~local/style.css', 'http://localhost:5173/style.css'],
|
||||
['~absolute/style.css', 'http://localhost:5173/assets/style.css'],
|
||||
['~file', 'http://localhost:5173/example.css'],
|
||||
// Absolute paths are loaded with the `/@fs/` base path
|
||||
[
|
||||
'~outside/test.css',
|
||||
`http://localhost:5173/@fs${
|
||||
process.platform === 'win32'
|
||||
? '/' + normalizePath(resolve('/some/non-root/test.css')) // "/D:/some/non-root/test.css"
|
||||
: '/some/non-root/test.css'
|
||||
}`,
|
||||
],
|
||||
// URLs should not be changed
|
||||
['https://example.com/style.css', 'https://example.com/style.css'],
|
||||
])('should transform "%s" into "%s"', (input, expected) => {
|
||||
const { document } = new Window({
|
||||
url: 'http://localhost',
|
||||
});
|
||||
const root = '/some/root';
|
||||
const config = fakeResolvedConfig({
|
||||
root,
|
||||
alias: {
|
||||
'~local': '.',
|
||||
'~absolute': `${root}/assets`,
|
||||
'~file': `${root}/example.css`,
|
||||
'~outside': `${root}/../non-root`,
|
||||
'~~': root,
|
||||
'~': root,
|
||||
},
|
||||
});
|
||||
const server = fakeDevServer({
|
||||
hostname: 'localhost',
|
||||
port: 5173,
|
||||
origin: 'http://localhost:5173',
|
||||
});
|
||||
const id = root + '/entrypoints/popup/index.html';
|
||||
|
||||
document.head.innerHTML = `<link rel="stylesheet" href="${input}" />`;
|
||||
pointToDevServer(config, server, id, document as any, 'link', 'href');
|
||||
|
||||
const actual = document.querySelector('link')!;
|
||||
expect(actual.getAttribute('href')).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type * as vite from 'vite';
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
import { ResolvedConfig } from '~/types';
|
||||
import path from 'node:path';
|
||||
|
||||
let increment = 0;
|
||||
|
||||
export function bundleAnalysis(
|
||||
config: Omit<ResolvedConfig, 'builder'>,
|
||||
): vite.Plugin {
|
||||
return visualizer({
|
||||
template: 'raw-data',
|
||||
filename: path.resolve(
|
||||
config.analysis.outputDir,
|
||||
`${config.analysis.outputName}-${increment++}.json`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated FOR TESTING ONLY.
|
||||
*/
|
||||
export function resetBundleIncrement() {
|
||||
increment = 0;
|
||||
}
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
import * as vite from 'vite';
|
||||
import { Entrypoint, InternalConfig } from '~/types';
|
||||
import type * as vite from 'vite';
|
||||
import { Entrypoint, ResolvedConfig } from '~/types';
|
||||
import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
|
||||
|
||||
/**
|
||||
@@ -14,7 +14,7 @@ import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
|
||||
*/
|
||||
export function cssEntrypoints(
|
||||
entrypoint: Entrypoint,
|
||||
config: InternalConfig,
|
||||
config: Omit<ResolvedConfig, 'builder'>,
|
||||
): vite.Plugin {
|
||||
return {
|
||||
name: 'wxt:css-entrypoint',
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Overrides definitions for `import.meta.*`
|
||||
*
|
||||
* - `import.meta.url`: Without this, background service workers crash trying to access
|
||||
* `document.location`, see https://github.com/wxt-dev/wxt/issues/392
|
||||
*/
|
||||
export function defineImportMeta() {
|
||||
return {
|
||||
name: 'wxt:define',
|
||||
config() {
|
||||
return {
|
||||
define: {
|
||||
// This works for all extension contexts, including background service worker
|
||||
'import.meta.url': 'self.location.href',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
+72
-28
@@ -1,8 +1,9 @@
|
||||
import * as vite from 'vite';
|
||||
import { InternalConfig } from '~/types';
|
||||
import type * as vite from 'vite';
|
||||
import { ResolvedConfig, WxtDevServer } from '~/types';
|
||||
import { getEntrypointName } from '~/core/utils/entrypoints';
|
||||
import { parseHTML } from 'linkedom';
|
||||
import { dirname, isAbsolute, relative, resolve } from 'path';
|
||||
import { dirname, relative, resolve } from 'node:path';
|
||||
import { normalizePath } from '~/core/utils/paths';
|
||||
|
||||
// Cache the preamble script for all devHtmlPrerender plugins, not just one
|
||||
let reactRefreshPreamble = '';
|
||||
@@ -10,7 +11,9 @@ let reactRefreshPreamble = '';
|
||||
/**
|
||||
* Pre-renders the HTML entrypoints when building the extension to connect to the dev server.
|
||||
*/
|
||||
export function devHtmlPrerender(config: InternalConfig): vite.PluginOption {
|
||||
export function devHtmlPrerender(
|
||||
config: Omit<ResolvedConfig, 'builder'>,
|
||||
): vite.PluginOption {
|
||||
const htmlReloadId = '@wxt/reload-html';
|
||||
const resolvedHtmlReloadId = resolve(
|
||||
config.root,
|
||||
@@ -45,25 +48,10 @@ export function devHtmlPrerender(config: InternalConfig): vite.PluginOption {
|
||||
|
||||
const { document } = parseHTML(code);
|
||||
|
||||
const pointToDevServer = (
|
||||
querySelector: string,
|
||||
attr: string,
|
||||
): void => {
|
||||
document.querySelectorAll(querySelector).forEach((element) => {
|
||||
const src = element.getAttribute(attr);
|
||||
if (!src) return;
|
||||
|
||||
if (isAbsolute(src)) {
|
||||
element.setAttribute(attr, server.origin + src);
|
||||
} else if (src.startsWith('.')) {
|
||||
const abs = resolve(dirname(id), src);
|
||||
const pathname = relative(config.root, abs);
|
||||
element.setAttribute(attr, `${server.origin}/${pathname}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
pointToDevServer('script[type=module]', 'src');
|
||||
pointToDevServer('link[rel=stylesheet]', 'href');
|
||||
const _pointToDevServer = (querySelector: string, attr: string) =>
|
||||
pointToDevServer(config, server, id, document, querySelector, attr);
|
||||
_pointToDevServer('script[type=module]', 'src');
|
||||
_pointToDevServer('link[rel=stylesheet]', 'href');
|
||||
|
||||
// Add a script to add page reloading
|
||||
const reloader = document.createElement('script');
|
||||
@@ -86,11 +74,7 @@ export function devHtmlPrerender(config: InternalConfig): vite.PluginOption {
|
||||
const originalUrl = `${server.origin}${ctx.path}`;
|
||||
const name = getEntrypointName(config.entrypointsDir, ctx.filename);
|
||||
const url = `${server.origin}/${name}.html`;
|
||||
const serverHtml = await server.transformIndexHtml(
|
||||
url,
|
||||
html,
|
||||
originalUrl,
|
||||
);
|
||||
const serverHtml = await server.transformHtml(url, html, originalUrl);
|
||||
const { document } = parseHTML(serverHtml);
|
||||
|
||||
// React pages include a preamble as an unsafe-inline type="module" script to enable fast refresh, as shown here:
|
||||
@@ -148,3 +132,63 @@ export function devHtmlPrerender(config: InternalConfig): vite.PluginOption {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function pointToDevServer(
|
||||
config: Omit<ResolvedConfig, 'builder'>,
|
||||
server: WxtDevServer,
|
||||
id: string,
|
||||
document: Document,
|
||||
querySelector: string,
|
||||
attr: string,
|
||||
) {
|
||||
document.querySelectorAll(querySelector).forEach((element) => {
|
||||
const src = element.getAttribute(attr);
|
||||
if (!src || isUrl(src)) return;
|
||||
|
||||
let resolvedAbsolutePath: string | undefined;
|
||||
|
||||
// Check if src uses a project alias
|
||||
const matchingAlias = Object.entries(config.alias).find(([key]) =>
|
||||
src.startsWith(key),
|
||||
);
|
||||
if (matchingAlias) {
|
||||
// Matches a import alias
|
||||
const [alias, replacement] = matchingAlias;
|
||||
resolvedAbsolutePath = resolve(
|
||||
config.root,
|
||||
src.replace(alias, replacement),
|
||||
);
|
||||
} else {
|
||||
// Some file path relative to the HTML file
|
||||
resolvedAbsolutePath = resolve(dirname(id), src);
|
||||
}
|
||||
|
||||
// Apply the final file path
|
||||
if (resolvedAbsolutePath) {
|
||||
const relativePath = normalizePath(
|
||||
relative(config.root, resolvedAbsolutePath),
|
||||
);
|
||||
|
||||
if (relativePath.startsWith('.')) {
|
||||
// Outside the config.root directory, serve the absolute path
|
||||
let path = normalizePath(resolvedAbsolutePath);
|
||||
// Add "/" to start of windows paths ("D:/some/path" -> "/D:/some/path")
|
||||
if (!path.startsWith('/')) path = '/' + path;
|
||||
element.setAttribute(attr, `${server.origin}/@fs${path}`);
|
||||
} else {
|
||||
// Inside the project, use relative path
|
||||
const url = new URL(relativePath, server.origin);
|
||||
element.setAttribute(attr, url.href);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isUrl(str: string): boolean {
|
||||
try {
|
||||
new URL(str);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Plugin } from 'vite';
|
||||
import { ResolvedConfig } from '~/types';
|
||||
|
||||
/**
|
||||
* Defines global constants about the dev server. Helps scripts connect to the server's web socket.
|
||||
*/
|
||||
export function devServerGlobals(
|
||||
config: Omit<ResolvedConfig, 'builder'>,
|
||||
): Plugin {
|
||||
return {
|
||||
name: 'wxt:dev-server-globals',
|
||||
config() {
|
||||
if (config.server == null || config.command == 'build') return;
|
||||
|
||||
return {
|
||||
define: {
|
||||
__DEV_SERVER_PROTOCOL__: JSON.stringify('ws:'),
|
||||
__DEV_SERVER_HOSTNAME__: JSON.stringify(config.server.hostname),
|
||||
__DEV_SERVER_PORT__: JSON.stringify(config.server.port),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Plugin } from 'vite';
|
||||
import { InternalConfig } from '~/types';
|
||||
import { ResolvedConfig } from '~/types';
|
||||
import { fetchCached } from '~/core/utils/network';
|
||||
|
||||
/**
|
||||
@@ -9,7 +9,7 @@ import { fetchCached } from '~/core/utils/network';
|
||||
* @example
|
||||
* import "url:https://google-tagmanager.com/gtag?id=XYZ";
|
||||
*/
|
||||
export function download(config: InternalConfig): Plugin {
|
||||
export function download(config: Omit<ResolvedConfig, 'builder'>): Plugin {
|
||||
return {
|
||||
name: 'wxt:download',
|
||||
resolveId(id) {
|
||||
@@ -0,0 +1,24 @@
|
||||
import type * as vite from 'vite';
|
||||
import { EntrypointGroup } from '~/types';
|
||||
import { getEntrypointGlobals } from '~/core/utils/globals';
|
||||
|
||||
/**
|
||||
* Define a set of global variables specific to an entrypoint.
|
||||
*/
|
||||
export function entrypointGroupGlobals(
|
||||
entrypointGroup: EntrypointGroup,
|
||||
): vite.PluginOption {
|
||||
return {
|
||||
name: 'wxt:entrypoint-group-globals',
|
||||
config() {
|
||||
const define: vite.InlineConfig['define'] = {};
|
||||
let name = Array.isArray(entrypointGroup) ? 'html' : entrypointGroup.name;
|
||||
for (const global of getEntrypointGlobals(name)) {
|
||||
define[`import.meta.env.${global.name}`] = JSON.stringify(global.value);
|
||||
}
|
||||
return {
|
||||
define,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
+5
-3
@@ -1,12 +1,14 @@
|
||||
import { InternalConfig } from '~/types';
|
||||
import * as vite from 'vite';
|
||||
import { ResolvedConfig } from '~/types';
|
||||
import type * as vite from 'vite';
|
||||
|
||||
/**
|
||||
* Apply the experimental config for disabling the polyfill. It works by aliasing the
|
||||
* `webextension-polyfill` module to a virtual module and exporting the `chrome` global from the
|
||||
* virtual module.
|
||||
*/
|
||||
export function excludeBrowserPolyfill(config: InternalConfig): vite.Plugin {
|
||||
export function excludeBrowserPolyfill(
|
||||
config: Omit<ResolvedConfig, 'builder'>,
|
||||
): vite.Plugin {
|
||||
const virtualId = 'virtual:wxt-webextension-polyfill-disabled';
|
||||
|
||||
return {
|
||||
@@ -0,0 +1,20 @@
|
||||
import type * as vite from 'vite';
|
||||
import { ResolvedConfig } from '~/types';
|
||||
import { getGlobals } from '~/core/utils/globals';
|
||||
|
||||
export function globals(
|
||||
config: Omit<ResolvedConfig, 'builder'>,
|
||||
): vite.PluginOption {
|
||||
return {
|
||||
name: 'wxt:globals',
|
||||
config() {
|
||||
const define: vite.InlineConfig['define'] = {};
|
||||
for (const global of getGlobals(config)) {
|
||||
define[`import.meta.env.${global.name}`] = JSON.stringify(global.value);
|
||||
}
|
||||
return {
|
||||
define,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export * from './noopBackground';
|
||||
export * from './cssEntrypoints';
|
||||
export * from './bundleAnalysis';
|
||||
export * from './globals';
|
||||
export * from './webextensionPolyfillAlias';
|
||||
export * from './webextensionPolyfillInlineDeps';
|
||||
export * from './webextensionPolyfillMock';
|
||||
export * from './excludeBrowserPolyfill';
|
||||
export * from './entrypointGroupGlobals';
|
||||
export * from './defineImportMeta';
|
||||
+28
-4
@@ -1,6 +1,6 @@
|
||||
import * as vite from 'vite';
|
||||
import { Entrypoint, InternalConfig } from '~/types';
|
||||
import { dirname, extname, resolve } from 'node:path';
|
||||
import type * as vite from 'vite';
|
||||
import { Entrypoint, ResolvedConfig } from '~/types';
|
||||
import { dirname, extname, resolve, join } from 'node:path';
|
||||
import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
|
||||
import fs, { ensureDir } from 'fs-extra';
|
||||
import { normalizePath } from '~/core/utils/paths';
|
||||
@@ -20,7 +20,7 @@ import { normalizePath } from '~/core/utils/paths';
|
||||
*/
|
||||
export function multipageMove(
|
||||
entrypoints: Entrypoint[],
|
||||
config: InternalConfig,
|
||||
config: Omit<ResolvedConfig, 'builder'>,
|
||||
): vite.Plugin {
|
||||
return {
|
||||
name: 'wxt:multipage-move',
|
||||
@@ -67,6 +67,30 @@ export function multipageMove(
|
||||
delete bundle[oldBundlePath];
|
||||
bundle[newBundlePath] = renamedChunk;
|
||||
}
|
||||
|
||||
// Remove directories that were created
|
||||
// TODO: Optimize and only delete old path directories
|
||||
removeEmptyDirs(config.outDir);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively remove all directories that are empty/
|
||||
*/
|
||||
export async function removeEmptyDirs(dir: string): Promise<void> {
|
||||
const files = await fs.readdir(dir);
|
||||
for (const file of files) {
|
||||
const filePath = join(dir, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
if (stats.isDirectory()) {
|
||||
await removeEmptyDirs(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rmdir(dir);
|
||||
} catch {
|
||||
// noop on failure - this means the directory was not empty.
|
||||
}
|
||||
}
|
||||
+4
-6
@@ -1,9 +1,9 @@
|
||||
import { Plugin } from 'vite';
|
||||
import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '~/core/utils/constants';
|
||||
|
||||
/**
|
||||
* In dev mode, if there's not a background script listed, we need to add one.
|
||||
*
|
||||
* This define's a virtual module that is basically just a noop.
|
||||
* In dev mode, if there's not a background script listed, we need to add one so that the web socket
|
||||
* connection is setup and the extension reloads HTML pages and content scripts correctly.
|
||||
*/
|
||||
export function noopBackground(): Plugin {
|
||||
const virtualModuleId = VIRTUAL_NOOP_BACKGROUND_MODULE_ID;
|
||||
@@ -15,10 +15,8 @@ export function noopBackground(): Plugin {
|
||||
},
|
||||
load(id) {
|
||||
if (id === resolvedVirtualModuleId) {
|
||||
return `import { defineBackground } from 'wxt/client';\nexport default defineBackground(() => void 0)`;
|
||||
return `import { defineBackground } from 'wxt/sandbox';\nexport default defineBackground(() => void 0)`;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const VIRTUAL_NOOP_BACKGROUND_MODULE_ID = 'virtual:user-background';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ResolvedConfig } from '~/types';
|
||||
import type * as vite from 'vite';
|
||||
|
||||
export function tsconfigPaths(
|
||||
config: Omit<ResolvedConfig, 'builder'>,
|
||||
): vite.Plugin {
|
||||
return {
|
||||
name: 'wxt:aliases',
|
||||
async config() {
|
||||
return {
|
||||
resolve: {
|
||||
alias: config.alias,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createUnimport } from 'unimport';
|
||||
import { ResolvedConfig } from '~/types';
|
||||
import { getUnimportOptions } from '~/core/utils/unimport';
|
||||
import type * as vite from 'vite';
|
||||
import { extname } from 'path';
|
||||
|
||||
const ENABLED_EXTENSIONS = new Set([
|
||||
'.js',
|
||||
'.jsx',
|
||||
'.ts',
|
||||
'.tsx',
|
||||
'.vue',
|
||||
'.svelte',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Inject any global imports defined by unimport
|
||||
*/
|
||||
export function unimport(
|
||||
config: Omit<ResolvedConfig, 'builder'>,
|
||||
): vite.PluginOption {
|
||||
const options = getUnimportOptions(config);
|
||||
if (options === false) return [];
|
||||
|
||||
const unimport = createUnimport(options);
|
||||
|
||||
return {
|
||||
name: 'wxt:unimport',
|
||||
async config() {
|
||||
await unimport.scanImportsFromDir(undefined, { cwd: config.srcDir });
|
||||
},
|
||||
async transform(code, id) {
|
||||
// Don't transform dependencies
|
||||
if (id.includes('node_modules')) return;
|
||||
|
||||
// Don't transform non-js files
|
||||
if (!ENABLED_EXTENSIONS.has(extname(id))) return;
|
||||
|
||||
const injected = await unimport.injectImports(code, id);
|
||||
return {
|
||||
code: injected.code,
|
||||
map: injected.s.generateMap({ hires: 'boundary', source: id }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
import { Plugin } from 'vite';
|
||||
import { Entrypoint, InternalConfig } from '~/types';
|
||||
import { ResolvedConfig, VirtualEntrypointType } from '~/types';
|
||||
import fs from 'fs-extra';
|
||||
import { resolve } from 'path';
|
||||
import { normalizePath } from '~/core/utils/paths';
|
||||
@@ -8,8 +8,8 @@ import { normalizePath } from '~/core/utils/paths';
|
||||
* Wraps a user's entrypoint with a vitual version with additional logic.
|
||||
*/
|
||||
export function virtualEntrypoint(
|
||||
type: Entrypoint['type'],
|
||||
config: InternalConfig,
|
||||
type: VirtualEntrypointType,
|
||||
config: Omit<ResolvedConfig, 'builder'>,
|
||||
): Plugin {
|
||||
const virtualId = `virtual:wxt-${type}?`;
|
||||
const resolvedVirtualId = `\0${virtualId}`;
|
||||
@@ -0,0 +1,41 @@
|
||||
import path from 'node:path';
|
||||
import type * as vite from 'vite';
|
||||
import { ResolvedConfig } from '~/types';
|
||||
|
||||
/**
|
||||
* Mock `webextension-polyfill` by inlining all dependencies that import it and adding a custom
|
||||
* alias so that Vite resolves to a mocked version of the module.
|
||||
*
|
||||
* There are two ways to mark a module as inline:
|
||||
* 1. Use partial file paths ("wxt/dist/browser.js") in the `test.server.deps.inline` option.
|
||||
* 2. Use module names ("wxt" or "@webext-core/messaging") in the `ssr.noExternalize` option.
|
||||
*
|
||||
* This plugin uses the second approach since it's a little more intuative to understand.
|
||||
*
|
||||
* TODO: Detect non-wxt dependencies (like `@webext-core/*`) that import `webextension-polyfill` via
|
||||
* `npm list` and inline them automatically.
|
||||
*/
|
||||
export function webextensionPolyfillMock(
|
||||
config: Omit<ResolvedConfig, 'builder'>,
|
||||
): vite.PluginOption {
|
||||
return {
|
||||
name: 'wxt:testing-inline-deps',
|
||||
config() {
|
||||
return {
|
||||
resolve: {
|
||||
alias: {
|
||||
// Alias to use a mocked version of the polyfill
|
||||
'webextension-polyfill': path.resolve(
|
||||
config.root,
|
||||
'node_modules/wxt/dist/virtual/mock-browser',
|
||||
),
|
||||
},
|
||||
},
|
||||
ssr: {
|
||||
// Inline all WXT modules
|
||||
noExternal: ['wxt'],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
+168
-141
@@ -2,14 +2,11 @@ import {
|
||||
BuildStepOutput,
|
||||
EntrypointGroup,
|
||||
InlineConfig,
|
||||
InternalConfig,
|
||||
ServerInfo,
|
||||
WxtDevServer,
|
||||
} from '~/types';
|
||||
import * as vite from 'vite';
|
||||
import type { Scripting } from 'webextension-polyfill';
|
||||
import {
|
||||
getEntrypointBundlePath,
|
||||
getEntrypointOutputFile,
|
||||
resolvePerBrowserOption,
|
||||
} from '~/core/utils/entrypoints';
|
||||
import {
|
||||
@@ -18,15 +15,16 @@ import {
|
||||
} from '~/core/utils/manifest';
|
||||
import {
|
||||
internalBuild,
|
||||
getInternalConfig,
|
||||
detectDevChanges,
|
||||
rebuild,
|
||||
findEntrypoints,
|
||||
} from '~/core/utils/building';
|
||||
import { createExtensionRunner } from '~/core/runners';
|
||||
import { consola } from 'consola';
|
||||
import { Mutex } from 'async-mutex';
|
||||
import pc from 'picocolors';
|
||||
import { relative } from 'node:path';
|
||||
import { registerWxt, wxt } from './wxt';
|
||||
|
||||
/**
|
||||
* Creates a dev server and pre-builds all the files that need to exist before loading the extension.
|
||||
@@ -38,64 +36,151 @@ import { relative } from 'node:path';
|
||||
* await server.start();
|
||||
*/
|
||||
export async function createServer(
|
||||
config?: InlineConfig,
|
||||
inlineConfig?: InlineConfig,
|
||||
): Promise<WxtDevServer> {
|
||||
const serverInfo = await getServerInfo();
|
||||
|
||||
const getLatestInternalConfig = async () => {
|
||||
return getInternalConfig(
|
||||
{
|
||||
...config,
|
||||
vite: () => serverInfo.viteServerConfig,
|
||||
},
|
||||
'serve',
|
||||
);
|
||||
const port = await getPort();
|
||||
const hostname = 'localhost';
|
||||
const origin = `http://${hostname}:${port}`;
|
||||
const serverInfo: ServerInfo = {
|
||||
port,
|
||||
hostname,
|
||||
origin,
|
||||
};
|
||||
|
||||
let internalConfig = await getLatestInternalConfig();
|
||||
const server = await setupServer(serverInfo, internalConfig);
|
||||
internalConfig.server = server;
|
||||
const buildAndOpenBrowser = async () => {
|
||||
// Build after starting the dev server so it can be used to transform HTML files
|
||||
server.currentOutput = await internalBuild();
|
||||
|
||||
// Open browser after everything is ready to go.
|
||||
await runner.openBrowser();
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops the previous runner, grabs the latest config, and recreates the runner.
|
||||
*/
|
||||
const closeAndRecreateRunner = async () => {
|
||||
await runner.closeBrowser();
|
||||
await wxt.reloadConfig();
|
||||
runner = await createExtensionRunner();
|
||||
};
|
||||
|
||||
// Server instance must be created first so its reference can be added to the internal config used
|
||||
// to pre-render entrypoints
|
||||
const server: WxtDevServer = {
|
||||
...serverInfo,
|
||||
get watcher() {
|
||||
return builderServer.watcher;
|
||||
},
|
||||
get ws() {
|
||||
return builderServer.ws;
|
||||
},
|
||||
currentOutput: undefined,
|
||||
async start() {
|
||||
await builderServer.listen();
|
||||
wxt.logger.success(`Started dev server @ ${serverInfo.origin}`);
|
||||
await buildAndOpenBrowser();
|
||||
},
|
||||
async stop() {
|
||||
await runner.closeBrowser();
|
||||
await builderServer.close();
|
||||
},
|
||||
async restart() {
|
||||
await closeAndRecreateRunner();
|
||||
await buildAndOpenBrowser();
|
||||
},
|
||||
transformHtml(url, html, originalUrl) {
|
||||
return builderServer.transformHtml(url, html, originalUrl);
|
||||
},
|
||||
reloadContentScript(contentScript) {
|
||||
server.ws.send('wxt:reload-content-script', contentScript);
|
||||
},
|
||||
reloadPage(path) {
|
||||
server.ws.send('wxt:reload-page', path);
|
||||
},
|
||||
reloadExtension() {
|
||||
server.ws.send('wxt:reload-extension');
|
||||
},
|
||||
async restartBrowser() {
|
||||
await closeAndRecreateRunner();
|
||||
await runner.openBrowser();
|
||||
},
|
||||
};
|
||||
|
||||
await registerWxt('serve', inlineConfig, server);
|
||||
|
||||
let [runner, builderServer] = await Promise.all([
|
||||
createExtensionRunner(),
|
||||
wxt.config.builder.createServer(server),
|
||||
]);
|
||||
|
||||
// Register content scripts for the first time after the background starts up since they're not
|
||||
// listed in the manifest
|
||||
server.ws.on('wxt:background-initialized', () => {
|
||||
if (server.currentOutput == null) return;
|
||||
reloadContentScripts(server.currentOutput.steps, server);
|
||||
});
|
||||
|
||||
// Listen for file changes and reload different parts of the extension accordingly
|
||||
const reloadOnChange = createFileReloader(server);
|
||||
server.watcher.on('all', reloadOnChange);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
async function getPort(): Promise<number> {
|
||||
const { default: getPort, portNumbers } = await import('get-port');
|
||||
return await getPort({ port: portNumbers(3000, 3010) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a function responsible for reloading different parts of the extension when a file
|
||||
* changes.
|
||||
*/
|
||||
function createFileReloader(server: WxtDevServer) {
|
||||
const fileChangedMutex = new Mutex();
|
||||
const changeQueue: Array<[string, string]> = [];
|
||||
|
||||
server.ws.on('wxt:background-initialized', () => {
|
||||
// Register content scripts for the first time since they're not listed in the manifest
|
||||
reloadContentScripts(server.currentOutput.steps, internalConfig, server);
|
||||
});
|
||||
return async (event: string, path: string) => {
|
||||
await wxt.reloadConfig();
|
||||
|
||||
server.watcher.on('all', async (event, path, _stats) => {
|
||||
// Here, "path" is a non-normalized path (ie: C:\\users\\... instead of C:/users/...)
|
||||
if (path.startsWith(internalConfig.outBaseDir)) return;
|
||||
if (path.startsWith(wxt.config.outBaseDir)) return;
|
||||
changeQueue.push([event, path]);
|
||||
|
||||
await fileChangedMutex.runExclusive(async () => {
|
||||
const fileChanges = changeQueue.splice(0, changeQueue.length);
|
||||
if (server.currentOutput == null) return;
|
||||
|
||||
const fileChanges = changeQueue
|
||||
.splice(0, changeQueue.length)
|
||||
.map(([_, file]) => file);
|
||||
if (fileChanges.length === 0) return;
|
||||
|
||||
const changes = detectDevChanges(fileChanges, server.currentOutput);
|
||||
if (changes.type === 'no-change') return;
|
||||
|
||||
if (changes.type === 'full-restart') {
|
||||
wxt.logger.info('Config changed, restarting server...');
|
||||
server.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (changes.type === 'browser-restart') {
|
||||
wxt.logger.info('Runner config changed, restarting browser...');
|
||||
server.restartBrowser();
|
||||
return;
|
||||
}
|
||||
|
||||
// Log the entrypoints that were effected
|
||||
internalConfig.logger.info(
|
||||
`Changed: ${Array.from(new Set(fileChanges.map((change) => change[1])))
|
||||
.map((file) => pc.dim(relative(internalConfig.root, file)))
|
||||
wxt.logger.info(
|
||||
`Changed: ${Array.from(new Set(fileChanges))
|
||||
.map((file) => pc.dim(relative(wxt.config.root, file)))
|
||||
.join(', ')}`,
|
||||
);
|
||||
const rebuiltNames = changes.rebuildGroups
|
||||
.flat()
|
||||
.map((entry) => {
|
||||
return pc.cyan(
|
||||
relative(internalConfig.outDir, getEntrypointOutputFile(entry, '')),
|
||||
);
|
||||
})
|
||||
.join(pc.dim(', '));
|
||||
|
||||
// Get latest config and Rebuild groups with changes
|
||||
internalConfig = await getLatestInternalConfig();
|
||||
internalConfig.server = server;
|
||||
// Rebuild entrypoints on change
|
||||
const allEntrypoints = await findEntrypoints();
|
||||
const { output: newOutput } = await rebuild(
|
||||
internalConfig,
|
||||
allEntrypoints,
|
||||
// TODO: this excludes new entrypoints, so they're not built until the dev command is restarted
|
||||
changes.rebuildGroups,
|
||||
changes.cachedOutput,
|
||||
@@ -106,125 +191,58 @@ export async function createServer(
|
||||
switch (changes.type) {
|
||||
case 'extension-reload':
|
||||
server.reloadExtension();
|
||||
consola.success(`Reloaded extension`);
|
||||
break;
|
||||
case 'html-reload':
|
||||
reloadHtmlPages(changes.rebuildGroups, server, internalConfig);
|
||||
const { reloadedNames } = reloadHtmlPages(
|
||||
changes.rebuildGroups,
|
||||
server,
|
||||
);
|
||||
consola.success(`Reloaded: ${getFilenameList(reloadedNames)}`);
|
||||
break;
|
||||
case 'content-script-reload':
|
||||
reloadContentScripts(changes.changedSteps, internalConfig, server);
|
||||
reloadContentScripts(changes.changedSteps, server);
|
||||
const rebuiltNames = changes.rebuildGroups
|
||||
.flat()
|
||||
.map((entry) => entry.name);
|
||||
consola.success(`Reloaded: ${getFilenameList(rebuiltNames)}`);
|
||||
break;
|
||||
}
|
||||
consola.success(`Reloaded: ${rebuiltNames}`);
|
||||
});
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
async function getServerInfo(): Promise<ServerInfo> {
|
||||
const { default: getPort, portNumbers } = await import('get-port');
|
||||
const port = await getPort({ port: portNumbers(3000, 3010) });
|
||||
const hostname = 'localhost';
|
||||
const origin = `http://${hostname}:${port}`;
|
||||
const serverConfig: vite.InlineConfig = {
|
||||
server: {
|
||||
origin,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
port,
|
||||
hostname,
|
||||
origin,
|
||||
viteServerConfig: serverConfig,
|
||||
};
|
||||
}
|
||||
|
||||
async function setupServer(
|
||||
serverInfo: ServerInfo,
|
||||
config: InternalConfig,
|
||||
): Promise<WxtDevServer> {
|
||||
const runner = await createExtensionRunner(config);
|
||||
|
||||
const viteServer = await vite.createServer(
|
||||
vite.mergeConfig(serverInfo, await config.vite(config.env)),
|
||||
);
|
||||
|
||||
const start = async () => {
|
||||
await viteServer.listen(server.port);
|
||||
config.logger.success(`Started dev server @ ${serverInfo.origin}`);
|
||||
|
||||
server.currentOutput = await internalBuild(config);
|
||||
await runner.openBrowser(config);
|
||||
};
|
||||
|
||||
const reloadExtension = () => {
|
||||
viteServer.ws.send('wxt:reload-extension');
|
||||
};
|
||||
const reloadPage = (path: string) => {
|
||||
// Can't use Vite's built-in "full-reload" event because it doesn't like our paths, it expects
|
||||
// paths ending in "/index.html"
|
||||
viteServer.ws.send('wxt:reload-page', path);
|
||||
};
|
||||
const reloadContentScript = (
|
||||
contentScript: Omit<Scripting.RegisteredContentScript, 'id'>,
|
||||
) => {
|
||||
viteServer.ws.send('wxt:reload-content-script', contentScript);
|
||||
};
|
||||
|
||||
const server: WxtDevServer = {
|
||||
...viteServer,
|
||||
start,
|
||||
currentOutput: {
|
||||
manifest: {
|
||||
manifest_version: 3,
|
||||
name: '',
|
||||
version: '',
|
||||
},
|
||||
publicAssets: [],
|
||||
steps: [],
|
||||
},
|
||||
port: serverInfo.port,
|
||||
hostname: serverInfo.hostname,
|
||||
origin: serverInfo.origin,
|
||||
reloadExtension,
|
||||
reloadPage,
|
||||
reloadContentScript,
|
||||
};
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* From the server, tell the client to reload content scripts from the provided build step outputs.
|
||||
*/
|
||||
function reloadContentScripts(
|
||||
steps: BuildStepOutput[],
|
||||
config: InternalConfig,
|
||||
server: WxtDevServer,
|
||||
) {
|
||||
if (config.manifestVersion === 3) {
|
||||
function reloadContentScripts(steps: BuildStepOutput[], server: WxtDevServer) {
|
||||
if (wxt.config.manifestVersion === 3) {
|
||||
steps.forEach((step) => {
|
||||
if (server.currentOutput == null) return;
|
||||
|
||||
const entry = step.entrypoints;
|
||||
if (Array.isArray(entry) || entry.type !== 'content-script') return;
|
||||
|
||||
const js = [getEntrypointBundlePath(entry, config.outDir, '.js')];
|
||||
const js = [getEntrypointBundlePath(entry, wxt.config.outDir, '.js')];
|
||||
const cssMap = getContentScriptsCssMap(server.currentOutput, [entry]);
|
||||
const css = getContentScriptCssFiles([entry], cssMap);
|
||||
|
||||
server.reloadContentScript({
|
||||
allFrames: resolvePerBrowserOption(
|
||||
entry.options.allFrames,
|
||||
config.browser,
|
||||
wxt.config.browser,
|
||||
),
|
||||
excludeMatches: resolvePerBrowserOption(
|
||||
entry.options.excludeMatches,
|
||||
config.browser,
|
||||
wxt.config.browser,
|
||||
),
|
||||
matches: resolvePerBrowserOption(entry.options.matches, config.browser),
|
||||
runAt: resolvePerBrowserOption(entry.options.runAt, config.browser),
|
||||
matches: resolvePerBrowserOption(
|
||||
entry.options.matches,
|
||||
wxt.config.browser,
|
||||
),
|
||||
runAt: resolvePerBrowserOption(entry.options.runAt, wxt.config.browser),
|
||||
// @ts-expect-error: Chrome accepts this, not typed in webextension-polyfill (https://developer.chrome.com/docs/extensions/reference/scripting/#type-RegisteredContentScript)
|
||||
world: resolvePerBrowserOption(entry.options.world, config.browser),
|
||||
world: resolvePerBrowserOption(entry.options.world, wxt.config.browser),
|
||||
js,
|
||||
css,
|
||||
});
|
||||
@@ -237,17 +255,26 @@ function reloadContentScripts(
|
||||
function reloadHtmlPages(
|
||||
groups: EntrypointGroup[],
|
||||
server: WxtDevServer,
|
||||
config: InternalConfig,
|
||||
) {
|
||||
groups.flat().forEach((entry) => {
|
||||
const path = getEntrypointBundlePath(entry, config.outDir, '.html');
|
||||
): { reloadedNames: string[] } {
|
||||
// groups might contain other files like background/content scripts, and we only care about the HTMl pages
|
||||
const htmlEntries = groups
|
||||
.flat()
|
||||
.filter((entry) => entry.inputPath.endsWith('.html'));
|
||||
|
||||
htmlEntries.forEach((entry) => {
|
||||
const path = getEntrypointBundlePath(entry, wxt.config.outDir, '.html');
|
||||
server.reloadPage(path);
|
||||
});
|
||||
|
||||
return {
|
||||
reloadedNames: htmlEntries.map((entry) => entry.name),
|
||||
};
|
||||
}
|
||||
|
||||
interface ServerInfo {
|
||||
port: number;
|
||||
hostname: string;
|
||||
origin: string;
|
||||
viteServerConfig: vite.InlineConfig;
|
||||
function getFilenameList(names: string[]): string {
|
||||
return names
|
||||
.map((name) => {
|
||||
return pc.cyan(name);
|
||||
})
|
||||
.join(pc.dim(', '));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user