Compare commits

...

72 Commits

Author SHA1 Message Date
GitHub Actions 1dd1370ed7 chore(release): v0.17.5 2024-03-01 18:01:15 +00:00
Aaron 9ff90d0acf feat: Download and override private packages for Firefox code review (#507) 2024-03-01 10:17:45 -06:00
Aaron Klinker b754435589 docs: Fix CLI generation 2024-02-29 12:13:28 -06:00
Aaron Klinker 195d2cceaf docs: Add docs about configuring the manifest as a function 2024-02-29 11:39:06 -06:00
Aaron 420d78b638 feat: Expose package management utils under wxt.pm (#502) 2024-02-28 21:38:35 -06:00
btea b35e499355 docs: Fix typos (#503) 2024-02-28 21:36:43 -06:00
Aaron 41c3d6d015 chore: Use JSZip for wxt zip, enabling future features (#501) 2024-02-28 14:57:29 -06:00
GitHub Actions 64610bad41 chore(release): v0.17.4 2024-02-28 18:28:34 +00:00
Aaron 45b7d7c67f fix: Resolve the path to node_modules/wxt correctly (#498) 2024-02-28 12:25:04 -06:00
Aaron 0bb2746869 feat: Add ResolvedConfig.wxtModuleDir, resolving the directory once (#497) 2024-02-28 11:52:46 -06:00
Aaron Klinker f9b0aa45f8 chore: Fix tests after template change 2024-02-28 11:34:15 -06:00
btea 6f6ce33bbd feat: Add basic content script to templates (#495)
Co-authored-by: Aaron Klinker <aaronklinker1@gmail.com>
2024-02-28 11:30:53 -06:00
Aaron Klinker e1c6020997 docs: Add testing example for ContentScriptContext
Apart of #491
2024-02-28 11:17:20 -06:00
Aaron Klinker 012bd7e67f docs: Update context usage 2024-02-28 11:16:56 -06:00
Aaron Klinker 35778f70cb docs: Update comparison page 2024-02-28 10:39:47 -06:00
Aaron Klinker cb4f9aa752 docs: Add CRXJS to comparison page 2024-02-28 08:05:39 -06:00
Aaron Klinker f80fb42483 docs: Fix typos 2024-02-28 07:42:22 -06:00
Leo Shklovskii 02c79ca244 docs: Added DocVersionRedirector to "Using WXT" section (#492) 2024-02-27 23:47:10 -06:00
Qiwei Yang 50e8c86ce9 doc: Add markdown sticky ntoes (#488) 2024-02-25 13:37:10 -06:00
GitHub Actions 6fbfc15be7 chore(release): v0.17.3 2024-02-24 16:43:31 +00:00
Aaron c793d3cf6b fix(storage): Revert #478 and run migrations when item is defined and properly wait for migrations before allowing read/writes (#487) 2024-02-24 10:40:03 -06:00
Aaron Klinker b5f4d8cc1b feat(storage): Guarantee storage.getItems returns values in the same order as requested 2024-02-24 08:56:45 -06:00
Aaron Klinker 77e6d1f5a4 fix: Content scripts crash when using storage.defineItem 2024-02-24 08:23:12 -06:00
GitHub Actions 3f149ac4fa chore(release): v0.17.2 2024-02-23 21:49:55 +00:00
Aaron c0dbffaa82 fix: Don't use sub-dependency binaries directly (#482) 2024-02-23 15:45:18 -06:00
Aaron Klinker 707f03a3eb Update changelog 2024-02-23 12:55:55 -06:00
GitHub Actions 8372462041 chore(release): v0.17.1 2024-02-23 18:54:07 +00:00
Aaron Klinker 3fbbe2ccde fix: Content scripts not loading in dev mode 2024-02-23 12:51:11 -06:00
Leo Shklovskii e7880fcde0 docs: Lots of small typo fixes (#480) 2024-02-23 11:45:58 -06:00
Aaron Klinker ee5e76e4b1 Update changelog 2024-02-22 14:24:38 -06:00
GitHub Actions 1ae8d6bc46 chore(release): v0.17.0 2024-02-22 17:55:31 +00:00
Aaron de7dd09f60 fix: Improve dev mode for content scripts registered at runtime (#474) 2024-02-22 11:52:37 -06:00
Aaron 1649785826 fix(storage)!: Only run migrations when the extension is updated (#478) 2024-02-22 11:46:59 -06:00
Aaron Klinker 91fc41cb15 docs(storage): Update docs 2024-02-22 11:46:59 -06:00
Aaron 9c80ad832b feat(storage)!: Improved support for default values on storage items (#477) 2024-02-22 11:46:48 -06:00
Aaron Klinker 86b7162d99 Update changelog 2024-02-21 08:24:26 -06:00
Aaron Klinker b8f8acd33d Fix typo 2024-02-21 08:24:09 -06:00
GitHub Actions d8905d23a4 chore(release): v0.16.11 2024-02-21 14:17:40 +00:00
Aaron 5d2d124e5a fix: Output main JS file for HTML entrypoints to chunks directory (#473) 2024-02-21 08:13:43 -06:00
Aaron Klinker 4fda203def chore(e2e): Remove log 2024-02-21 08:13:21 -06:00
Aaron Klinker 905bfc7a38 ci: Upgrade pnpm/action-setup to v2 2024-02-20 14:35:53 -06:00
Aaron Klinker 7c6973f0e6 ci: Fix codecov warning in release workflow 2024-02-20 14:35:08 -06:00
GitHub Actions 969e39c541 chore(release): v0.16.10 2024-02-20 20:33:12 +00:00
Aaron b256939cc4 fix: Respect configFile option (#472) 2024-02-20 14:30:21 -06:00
Aaron c46137644b feat: Customize when content scripts are registered, in the manifest or at runtime (#471) 2024-02-20 14:18:53 -06:00
Aaron 1bd7ef56c4 fix: Don't assume react when importing JSX entrypoints during build (#470) 2024-02-20 13:53:00 -06:00
Aaron Klinker c258b99cec Update changelog 2024-02-20 08:26:02 -06:00
GitHub Actions d3abb32cb4 chore(release): v0.16.9 2024-02-20 14:17:52 +00:00
Aaron 223ede9ff9 feat: Support setting side panel options in HTML file (#468) 2024-02-20 08:14:34 -06:00
Aaron 9d31a4eba1 chore: Simplify entrypoint types (#464) 2024-02-19 16:17:54 -06:00
okou ea0e40c269 fix: Fix order of ShadowRootUI hooks calling (#459) 2024-02-19 07:45:57 -06:00
Aaron Klinker bc24ea45a4 docs: Add wrapper div to react's createShadowRootUi example
Related to #456.
2024-02-17 09:20:35 -06:00
GitHub Actions 86d0c9b397 chore(release): v0.16.8 2024-02-15 21:41:43 +00:00
Aaron c275ccf0da fix: Watch files outside project root during development (#454) 2024-02-15 14:23:32 -06:00
Aaron Klinker 447a48f306 docs: Add loading and error states for "Who's using WXT" section 2024-02-15 10:37:58 -06:00
GitHub Actions 70b5cbc8db chore(release): v0.16.7 2024-02-15 16:35:13 +00:00
Aaron 3459de0375 perf: Upgrade Vite to 5.1 (#452) 2024-02-15 10:31:20 -06:00
Aaron c6817479bd feat: Generate ESLint globals file for auto-imports (#450) 2024-02-15 10:14:57 -06:00
Aaron f02aae21f6 chore(e2e): Use wxt prepare instead of wxt build when possible to speed up E2E tests (#451) 2024-02-15 09:59:36 -06:00
Aaron Klinker 87a62a1791 docs: Remove anchor from content script ui examples 2024-02-14 14:43:34 -06:00
Aaron Klinker a0d1643021 docs: Add section about dev mode differences 2024-02-14 14:41:40 -06:00
William Gérald Blondel 856a946fa7 Fix typo on homepage hero section (#438) 2024-02-11 10:13:17 -06:00
GitHub Actions 9d97164c20 chore(release): v0.16.6 2024-02-10 10:29:52 +00:00
lionelhorn 6d526e7043 feat: Add option to customize the analysis artifacts output (#431)
Co-authored-by: Aaron Klinker <aaronklinker1@gmail.com>
2024-02-10 04:26:33 -06:00
Aaron Klinker 946072fc5a docs: Fix createShadowRootUi unmount calls 2024-02-10 04:04:16 -06:00
Aaron Klinker 6b8dfdf02d chore: Enable skipped test since it works now 2024-02-10 03:01:38 -06:00
okou ba85fdf470 fix: Use insertBefore on mounting content script UI 2024-02-10 03:01:38 -06:00
okou 295f8601e3 refactor: Use Element.prepend on mounting UI 2024-02-10 03:01:38 -06:00
Aaron a38ccd8624 doc: Fix custom block 2024-02-09 05:20:51 -06:00
GitHub Actions b2dcec2f8a chore(release): v0.16.5 2024-02-09 06:01:00 +00:00
Aaron Klinker e8355029d9 fix: Support node 20 when running wxt submit 2024-02-08 23:55:53 -06:00
Aaron Klinker 2b374b98d2 docs: Remove "coming soon" from automated publishing feature 2024-02-05 23:36:13 -06:00
107 changed files with 3502 additions and 1326 deletions
+3
View File
@@ -1,2 +1,5 @@
* text=auto eol=lf
pnpm-lock.yaml linguist-generated
package-lock.json linguist-generated
bun.lockb linguist-generated
yarn.lock linguist-generated
+1 -1
View File
@@ -4,7 +4,7 @@ runs:
using: composite
steps:
- name: Setup PNPM
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v3
with:
version: 8
- name: Setup NodeJS
+1
View File
@@ -5,6 +5,7 @@ on:
jobs:
validate:
uses: './.github/workflows/validate.yml'
secrets: inherit
publish:
runs-on: ubuntu-22.04
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: pnpm/action-setup@v3
with:
version: 8
- uses: actions/setup-node@v4
+1
View File
@@ -43,6 +43,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- uses: oven-sh/setup-bun@v1
- name: pnpm test:coverage
run: pnpm test:coverage --reporter=default --reporter=hanging-process
- uses: codecov/codecov-action@v4
+304 -5
View File
@@ -1,5 +1,304 @@
# Changelog
## v0.17.5
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.17.4...v0.17.5)
### 🚀 Enhancements
- Expose package management utils under `wxt.pm` ([#502](https://github.com/wxt-dev/wxt/pull/502))
- Download and override private packages for Firefox code review ([#507](https://github.com/wxt-dev/wxt/pull/507))
### 📖 Documentation
- Fix typos ([#503](https://github.com/wxt-dev/wxt/pull/503))
- Add docs about configuring the manifest as a function ([195d2cc](https://github.com/wxt-dev/wxt/commit/195d2cc))
- Fix CLI generation ([b754435](https://github.com/wxt-dev/wxt/commit/b754435))
### 🏡 Chore
- Use JSZip for `wxt zip`, enabling future features ([#501](https://github.com/wxt-dev/wxt/pull/501))
### ❤️ Contributors
- Btea ([@btea](http://github.com/btea))
## v0.17.4
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.17.3...v0.17.4)
### 🚀 Enhancements
- Add basic content script to templates ([#495](https://github.com/wxt-dev/wxt/pull/495))
- Add `ResolvedConfig.wxtModuleDir`, resolving the directory once ([#497](https://github.com/wxt-dev/wxt/pull/497))
### 🩹 Fixes
- Resolve the path to `node_modules/wxt` correctly ([#498](https://github.com/wxt-dev/wxt/pull/498))
### 📖 Documentation
- Added DocVersionRedirector to "Using WXT" section ([#492](https://github.com/wxt-dev/wxt/pull/492))
- Fix typos ([f80fb42](https://github.com/wxt-dev/wxt/commit/f80fb42))
- Add CRXJS to comparison page ([cb4f9aa](https://github.com/wxt-dev/wxt/commit/cb4f9aa))
- Update comparison page ([35778f7](https://github.com/wxt-dev/wxt/commit/35778f7))
- Update context usage ([012bd7e](https://github.com/wxt-dev/wxt/commit/012bd7e))
- Add testing example for `ContentScriptContext` ([e1c6020](https://github.com/wxt-dev/wxt/commit/e1c6020))
### 🏡 Chore
- Fix tests after template change ([f9b0aa4](https://github.com/wxt-dev/wxt/commit/f9b0aa4))
### ❤️ Contributors
- Btea ([@btea](http://github.com/btea))
- Leo Shklovskii <leo@thermopylae.net>
## v0.17.3
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.17.2...v0.17.3)
### 🚀 Enhancements
- **storage:** Guarantee `storage.getItems` returns values in the same order as requested ([b5f4d8c](https://github.com/wxt-dev/wxt/commit/b5f4d8c))
### 🩹 Fixes
- Content scripts crash when using `storage.defineItem` ([77e6d1f](https://github.com/wxt-dev/wxt/commit/77e6d1f))
- **storage:** Revert #478 and run migrations when item is defined and properly wait for migrations before allowing read/writes ([#487](https://github.com/wxt-dev/wxt/pull/487), [#478](https://github.com/wxt-dev/wxt/issues/478))
## v0.17.2
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.17.1...v0.17.2)
### 🩹 Fixes
- Don't use sub-dependency binaries directly ([#482](https://github.com/wxt-dev/wxt/pull/482))
## v0.17.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.17.0...v0.17.1)
### 🩹 Fixes
- Content scripts not loading in dev mode ([3fbbe2c](https://github.com/wxt-dev/wxt/commit/3fbbe2c))
### 📖 Documentation
- Lots of small typo fixes ([#480](https://github.com/wxt-dev/wxt/pull/480))
### ❤️ Contributors
- Leo Shklovskii ([@leos](https://github.com/leos))
## v0.17.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.16.11...v0.17.0)
### 🚀 Enhancements
- **storage:** ⚠️ Improved support for default values on storage items ([#477](https://github.com/wxt-dev/wxt/pull/477))
### 🩹 Fixes
- **storage:** ⚠️ Only run migrations when the extension is updated ([#478](https://github.com/wxt-dev/wxt/pull/478))
- Improve dev mode for content scripts registered at runtime ([#474](https://github.com/wxt-dev/wxt/pull/474))
### 📖 Documentation
- **storage:** Update docs ([91fc41c](https://github.com/wxt-dev/wxt/commit/91fc41c))
#### ⚠️ Breaking Changes
`v0.17.0` introduces several breaking changes to `wxt/storage`.
First, if you were using `defineItem` with versioning and no default value, you will need to add `defaultValue: null` to the options and update the first type parameter:
```ts
// < 0.17
const item = storage.defineItem<number>("local:count", {
version: ...,
migrations: ...,
})
// >= 0.17
const item = storage.defineItem<number | null>("local:count", {
defaultValue: null,
version: ...,
migrations: ...,
})
```
The `defaultValue` property is now required if passing in the second options argument.
If you exclude the second options argument, it will default to being nullable, as before.
```ts
const item: WxtStorageItem<number | null> = storage.defineItem<number>("local:count");
const value: number | null = await item.getValue();
```
> If you don't use typescript, there aren't any breaking changes, this is just a type change.
For storage items that are not nullable, the `watch` callback types has improved and will use the default value instead of `null` when the value is missing:
```ts
// >=0.17
const item = storage.defineItem<number>("local:count", { defaultValue: 0 });
item.watch((newValue: number | null, oldValue: number | null) => {
// ...
});
// >=0.17
const item = storage.defineItem<number>("local:count", { defaultValue: 0 });
item.watch((newValue: number, oldValue: number) => {
// ...
});
```
You can also access the default value directly off the item:
```ts
console.log(item.defaultValue); // 0
```
The second breaking change is that migrations for versioned items only run when the extension is updated. Before, they were ran whenever the storage item was created, in any entrypoint (background, popup, content script, etc). Now, in v0.17, storage items will only run migrations when the `browser.runtime.onInstalled` event is fired with `reason = "update"` in the background. See the updated docs to make sure they run correctly: https://wxt.dev/guide/storage.html#running-migrations. TLDR: you need to import all storage items into the background entrypoint for the `onInstalled` hook to fire properly and thus run the migrations.
To keep the old behavior, call the new `migrate` function to run migrations as soon as an item is defined:
```ts
const item = storage.defineItem(...);
item.migrate();
```
## v0.16.11
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.16.10...v0.16.11)
### 🩹 Fixes
- Output main JS file for HTML entrypoints to chunks directory ([#473](https://github.com/wxt-dev/wxt/pull/473))
### 🏡 Chore
- **e2e:** Remove log ([4fda203](https://github.com/wxt-dev/wxt/commit/4fda203))
### 🤖 CI
- Fix codecov warning in release workflow ([7c6973f](https://github.com/wxt-dev/wxt/commit/7c6973f))
- Upgrade `pnpm/action-setup` to v3 ([905bfc7](https://github.com/wxt-dev/wxt/commit/905bfc7))
## v0.16.10
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.16.9...v0.16.10)
### 🚀 Enhancements
- Customize when content scripts are registered, in the manifest or at runtime ([#471](https://github.com/wxt-dev/wxt/pull/471))
### 🩹 Fixes
- Don't assume react when importing JSX entrypoints during build ([#470](https://github.com/wxt-dev/wxt/pull/470))
- Respect `configFile` option ([#472](https://github.com/wxt-dev/wxt/pull/472))
## v0.16.9
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.16.8...v0.16.9)
### 🚀 Enhancements
- Support setting side panel options in HTML file ([#468](https://github.com/wxt-dev/wxt/pull/468))
### 🩹 Fixes
- Fix order of ShadowRootUI hooks calling ([#459](https://github.com/wxt-dev/wxt/pull/459))
### 📖 Documentation
- Add wrapper div to react's `createShadowRootUi` example ([bc24ea4](https://github.com/wxt-dev/wxt/commit/bc24ea4))
### 🏡 Chore
- Simplify entrypoint types ([#464](https://github.com/wxt-dev/wxt/pull/464))
### ❤️ Contributors
- Okou ([@ookkoouu](https://github.com/ookkoouu))
## v0.16.8
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.16.7...v0.16.8)
### 🩹 Fixes
- Watch files outside project root during development ([#454](https://github.com/wxt-dev/wxt/pull/454))
### 📖 Documentation
- Add loading and error states for "Who's using WXT" section ([447a48f](https://github.com/wxt-dev/wxt/commit/447a48f))
## v0.16.7
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.16.6...v0.16.7)
### 🚀 Enhancements
- Generate ESLint globals file for auto-imports ([#450](https://github.com/wxt-dev/wxt/pull/450))
### 🔥 Performance
- Upgrade Vite to 5.1 ([#452](https://github.com/wxt-dev/wxt/pull/452))
### 📖 Documentation
- Add section about dev mode differences ([a0d1643](https://github.com/wxt-dev/wxt/commit/a0d1643))
- Remove anchor from content script ui examples ([87a62a1](https://github.com/wxt-dev/wxt/commit/87a62a1))
### 🏡 Chore
- **e2e:** Use `wxt prepare` instead of `wxt build` when possible to speed up E2E tests ([#451](https://github.com/wxt-dev/wxt/pull/451))
## 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 ([@Lionelhorn](https://github.com/Lionelhorn))
- Okou ([@ookkoouu](https://github.com/ookkoouu))
## 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)
@@ -706,7 +1005,7 @@ Renamed undocumented constants for detecting the build config at runtime in [#38
### 🏡 Chore
- Remove whitespace from genearted `.wxt` files ([#211](https://github.com/wxt-dev/wxt/pull/211))
- Remove whitespace from generated `.wxt` files ([#211](https://github.com/wxt-dev/wxt/pull/211))
- Upgrade templates to `wxt@^0.9.0` ([#214](https://github.com/wxt-dev/wxt/pull/214))
- Update Vite dependency range to `^4.0.0 || ^5.0.0-0` ([f1e8084](https://github.com/wxt-dev/wxt/commit/f1e8084be89e512dde441b9197a99183c497f67d))
@@ -1214,8 +1513,8 @@ Renamed undocumented constants for detecting the build config at runtime in [#38
- **deps-dev:** Bump vitest from 0.34.1 to 0.34.3 ([#99](https://github.com/wxt-dev/wxt/pull/99))
- Increase E2E test timeout because GitHub Actions Window runner is slow ([2a0842b](https://github.com/wxt-dev/wxt/commit/2a0842b))
- **deps-dev:** Bump vitepress from 1.0.0-rc.4 to 1.0.0-rc.10 ([#96](https://github.com/wxt-dev/wxt/pull/96))
- Fix test watcher restarting indefinetly ([2c7922c](https://github.com/wxt-dev/wxt/commit/2c7922c))
- Remove explict icon config from templates ([93bfee0](https://github.com/wxt-dev/wxt/commit/93bfee0))
- Fix test watcher restarting indefinitely ([2c7922c](https://github.com/wxt-dev/wxt/commit/2c7922c))
- Remove explicit icon config from templates ([93bfee0](https://github.com/wxt-dev/wxt/commit/93bfee0))
- Use import aliases in Vue template ([#104](https://github.com/wxt-dev/wxt/pull/104))
#### ⚠️ Breaking Changes
@@ -1275,7 +1574,7 @@ Renamed undocumented constants for detecting the build config at runtime in [#38
- Branding and logo ([#60](https://github.com/wxt-dev/wxt/pull/60))
- Simplify binary setup ([#62](https://github.com/wxt-dev/wxt/pull/62))
- Add Solid template ([#63](https://github.com/wxt-dev/wxt/pull/63))
- Increate E2E test timeout to fix flakey test ([dfe424f](https://github.com/wxt-dev/wxt/commit/dfe424f))
- Increase E2E test timeout to fix flakey test ([dfe424f](https://github.com/wxt-dev/wxt/commit/dfe424f))
### 🤖 CI
@@ -1551,7 +1850,7 @@ Initial release of WXT. Full support for production builds and initial toolkit f
- Export and bootstrap the `/client` package ([5b07c95](https://github.com/wxt-dev/wxt/commit/5b07c95))
- Resolve entrypoints based on filesystem ([a63f061](https://github.com/wxt-dev/wxt/commit/a63f061))
- Separate output directories for each browser/manifest version ([f09ffbb](https://github.com/wxt-dev/wxt/commit/f09ffbb))
- Build entrypoints and output `manfiest.json` ([1e7c738](https://github.com/wxt-dev/wxt/commit/1e7c738))
- Build entrypoints and output `manifest.json` ([1e7c738](https://github.com/wxt-dev/wxt/commit/1e7c738))
- Automatically add CSS files to content scripts ([047ce04](https://github.com/wxt-dev/wxt/commit/047ce04))
- Download and bundle remote URL imports ([523c7df](https://github.com/wxt-dev/wxt/commit/523c7df))
- Generate type declarations and config for project types and auto-imports ([21debad](https://github.com/wxt-dev/wxt/commit/21debad))
+1 -1
View File
@@ -108,7 +108,7 @@ Then run `npm i` again.
To add a template, copy the vanilla template and give it a new name.
```sh
cp -r templates/vailla templates/<new-template-name>
cp -r templates/vanilla templates/<new-template-name>
```
That's it. Once your template is merged, it will be available inside `wxt init` immediately. You don't need to release a new version of WXT to release a new template.
+2 -5
View File
@@ -55,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
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env node
/**
* A alias around `publish-extension` that is always installed on the path without having to install
* `publish-browser-extension` as a direct dependency (like for PNPM, which doesn't link
* sub-dependency binaries to "node_modules/.bin")
*/
require('publish-browser-extension/cli');
+1 -1
View File
@@ -19,7 +19,7 @@
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"vitest": "^1.1.0"
"vitest": "^1.2.2"
},
"devDependencies": {
"@types/react": "^18.2.34",
+3
View File
@@ -15,4 +15,7 @@ export default defineConfig({
alias: {
public: 'src/public',
},
zip: {
downloadPackages: ['sass'],
},
});
+14 -2
View File
@@ -18,13 +18,16 @@ const chromeExtensionIds = [
'lknmjhcajhfbbglglccadlfdjbaiifig', // tl;dv - Record, Transcribe & ChatGPT for Google Meet
'youtube中文配音/oglffgiaiekgeicdgkdlnlkhliajdlja', // Youtube中文配音
'agjnjboanicjcpenljmaaigopkgdnihi', // PreMiD
'aiakblgmlabokilgljkglggnpflljdgp', // Markdown Sticky Notes
'nomnkbngkijpffepcgbbofhcnafpkiep', // DocVersionRedirector
];
const { data } = useListExtensionDetails(chromeExtensionIds);
const { data, err, isLoading } = useListExtensionDetails(chromeExtensionIds);
const sortedExtensions = computed(() => {
if (!data.value?.length) return [];
return [...data.value]
.filter((item) => item != null)
.map((item) => ({
...item,
// Sort based on the user count weighted by the rating
@@ -49,7 +52,16 @@ function getStoreUrl(extension: ChromeExtension) {
Battle tested and ready for production. Explore chrome extensions made
with WXT.
</p>
<ul>
<p v-if="isLoading" style="text-align: center; opacity: 50%">
Loading...
</p>
<p
v-else-if="err || sortedExtensions.length === 0"
style="text-align: center; opacity: 50%"
>
Failed to load extension details.
</p>
<ul v-else>
<li
v-for="extension of sortedExtensions"
:key="extension.id"
@@ -26,6 +26,7 @@ const query = `query ${operationName}($ids:[String!]!) {
export default function (ids: string[]) {
const data = ref<ChromeExtension[]>();
const err = ref<unknown>();
const isLoading = ref(true);
fetch('https://queue.wxt.dev/api', {
method: 'POST',
@@ -36,6 +37,7 @@ export default function (ids: string[]) {
}),
})
.then(async (res) => {
isLoading.value = false;
const {
data: { chromeExtensions },
} = await res.json();
@@ -43,6 +45,7 @@ export default function (ids: string[]) {
err.value = undefined;
})
.catch((error) => {
isLoading.value = false;
console.error(error);
data.value = undefined;
err.value = error;
@@ -51,5 +54,6 @@ export default function (ids: string[]) {
return {
data,
err,
isLoading,
};
}
+2 -2
View File
@@ -19,7 +19,7 @@ const title = 'Next-gen Web Extension Framework';
const titleSuffix = ' WXT';
const description =
"WXT provides the best developer experience, making it quick, easy, and fun to develop chrome extensions for all browsers. With built-in utilties for building, zipping, and publishing your extension, it's easy to get started.";
"WXT provides the best developer experience, making it quick, easy, and fun to develop chrome extensions for all browsers. With built-in utilities for building, zipping, and publishing your extension, it's easy to get started.";
const ogTitle = `${title}${titleSuffix}`;
const ogUrl = 'https://wxt.dev';
const ogImage = 'https://wxt.dev/social-preview.png';
@@ -124,7 +124,7 @@ export default defineConfig({
{ text: 'Options', link: '/entrypoints/options.md' },
{ text: 'Popup', link: '/entrypoints/popup.md' },
{ text: 'Sandbox', link: '/entrypoints/sandbox.md' },
{ text: 'Sidepanel', link: '/entrypoints/sidepanel.md' },
{ text: 'Side Panel', link: '/entrypoints/sidepanel.md' },
{ text: 'Unlisted Pages', link: '/entrypoints/unlisted-pages.md' },
{
text: 'Unlisted Scripts',
+8 -2
View File
@@ -32,14 +32,20 @@ export function generateCliDocs() {
'```',
];
const commands = await Promise.all(
const commands = await Promise.allSettled(
extractCommands(dev.info).map(async (command) => {
const res = await execaCommand(`pnpm -s wxt ${command} --help`);
const { rest: docs } = splitInfo(res.stdout);
return [`## \`wxt ${command}\``, '```sh', docs, '```'];
}),
);
lines.push(...commands);
lines.push(
...commands
.filter((res) => res.status === 'fulfilled')
.map(
(res) => (res as unknown as PromiseFulfilledResult<string>).value,
),
);
const text = await format(
PREFACE +
+1 -1
View File
@@ -16,7 +16,7 @@ For MV2, the background is added as a script to the background page. For MV3, th
## Definition
:::warning
The main function of the background **_CANNOT BE ASYNC_**. Event listeners must be added syncronously on background startup. If your main function returns a promise, WXT will log an error.
The main function of the background **_CANNOT BE ASYNC_**. Event listeners must be added synchronously on background startup. If your main function returns a promise, WXT will log an error.
:::
```ts
+19 -7
View File
@@ -37,6 +37,9 @@ export default defineContentScript({
// Configure how CSS is injected onto the page
cssInjectionMode: undefined | "manifest" | "manual" | "ui",
// Configure how/when content script will be registered
registration: undefined | "manifest" | "runtime",
main(ctx) {
// Executed when content script is loaded
},
@@ -49,9 +52,11 @@ When defining multiple content scripts, content script entrypoints that have the
## Context
Old content scripts are not automatically stopped when an extension updates and reloads. Often, this leads to "Invalidated context" errors in production when a content script from an old version of your extension tries to use a extension API.
Old content scripts are not automatically stopped when an extension updates and reloads. Often, this leads to "Invalidated context" errors in production when a content script from an old version of your extension tries to use a web extension API (ie, the `browser` or `chrome` globals).
WXT provides a utility for managing this process: `ContentScriptContext`. An instance of this class is provided to you automatically inside the `main` function of your content script.
WXT provides a utility for handling this process: `ContentScriptContext`. An instance of this class is provided to you automatically inside the `main` function of your content script.
When your extension updates or is uninstalled, the context will become invalidated, and will trigger any `ctx.onInvalidated` listeners you add:
```ts
export default defineContentScript({
@@ -62,26 +67,33 @@ export default defineContentScript({
// ...
});
// Stop fetch requests
fetch('...url', { signal: ctx.signal });
// Timeout utilities
// Timeout utilities that are automatically cleared when invalidated
ctx.setTimeout(() => {
// ...
}, 5e3);
ctx.setInterval(() => {
// ...
}, 60e3);
// Or add event listeners that get removed when invalidated
ctx.addEventListener(document, 'visibilitychange', (event) => {
// ...
});
// You can also stop fetch requests
fetch('...url', { signal: ctx.signal });
},
});
```
The class extends [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) and provides other utilities for stopping a content script's logic once it becomes invalidated.
:::tip
:::warning
When working with content scripts, **you should always use the `ctx` object to stop any async or future work.**
This prevents old content scripts from interfering with new content scripts, and prevents error messages from the console in production.
If you're using a framework like React, Vue, Svelte, etc., make sure you're unmounting your UI properly in the `onRemove` option of [`createShadowRootUi`](https://wxt.dev/guide/content-script-ui.html#shadow-root).
:::
## CSS
+1 -1
View File
@@ -2,7 +2,7 @@
WXT can build CSS entrypoints individually. CSS entrypoints are always unlisted.
See [Content Script CSS](/entrypoints/content-scripts#css) documentation for the recomended approach to include CSS with a content script.
See [Content Script CSS](/entrypoints/content-scripts#css) documentation for the recommended approach to include CSS with a content script.
:::info
If the recommended approach doesn't work for your use case, you can use any of the filename patterns below to build the styles separate from the JS and use the [`transformManifest` hook](/api/wxt/interfaces/InlineConfig#transformmanifest) to manually add your CSS file to the manifest.
+14 -2
View File
@@ -2,8 +2,10 @@
[Chrome Docs](https://developer.chrome.com/docs/extensions/reference/sidePanel/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/user_interface/Sidebars)
In Chrome, side panels use the "side_panel" API, while Firefox uses the "sidebar_action" API.
:::warning
Chrome added support for sidepanels in Manifest V3, they are not available in Manfiest V2.
Chrome added support for sidepanels in Manifest V3, they are not available in Manifest V2.
:::
## Filenames
@@ -25,7 +27,17 @@ Chrome added support for sidepanels in Manifest V3, they are not available in Ma
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
<title>Default Side Panel Title</title>
<meta
name="manifest.default_icon"
content="{
16: '/icon-16.png',
24: '/icon-24.png',
...
}"
/>
<meta name="manifest.open_at_install" content="true|false" />
<meta name="manifest.browser_style" content="true|false" />
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
+1 -1
View File
@@ -65,7 +65,7 @@ import { defineConfig } from 'wxt';
export default defineConfig({
imports: {
// Add auto-imports for vue fuctions like createApp, ref, computed, watch, toRaw, etc...
// Add auto-imports for vue functions like createApp, ref, computed, watch, toRaw, etc...
presets: ['vue'],
},
});
+30 -29
View File
@@ -1,46 +1,47 @@
# Compare
Lets compare the features of WXT vs [Plasmo](https://docs.plasmo.com/framework), another web extension framework.
Lets compare the features of WXT vs [Plasmo](https://docs.plasmo.com/framework) (another web extension framework) and [CRXJS](https://crxjs.dev/vite-plugin) (the most popular bundler plugin).
## Overview
| Features | WXT | Plasmo |
| ---------------------------------------------------- | :-------------------------: | :--------------------------------------: |
| Supports all browsers | ✅ | ✅ |
| MV2 Support | ✅ | ✅ |
| MV3 Support | ✅ | ✅ |
| Create Extension ZIPs | ✅ | ✅ |
| Create Firefox Sources ZIP | ✅ | ❌ |
| First-class TypeScript support | ✅ | ✅ |
| File based entrypoint discovery | | |
| Inline entrypoint config | ✅ | ✅ |
| Auto-imports | ✅ | ❌ |
| Supports all frontend frameworks | ✅ | 🟡 Only React, Vue, and Svelte |
| Framework specific entrypoints (like `Popup.tsx`) | 🟡 `.html` `.ts` `.tsx` | ✅ `.html` `.ts` `.tsx` `.vue` `.svelte` |
| Automated publishing | 🟡 Coming soon | ✅ |
| Remote Code Bundling (Google Analytics) | ✅ | ✅ |
| Features | WXT | Plasmo | CRXJS |
| ---------------------------------------------------- | :-------------------------: | :--------------------------------------: | :---------------------------------------------------------------------: |
| Supports all browsers | ✅ | ✅ | ❌ See [#56](https://github.com/crxjs/chrome-extension-tools/issues/56) |
| MV2 Support | ✅ | ✅ | 🟡 Either MV2 or MV3 |
| MV3 Support | ✅ | ✅ | 🟡 Either MV2 or MV3 |
| Create Extension ZIPs | ✅ | ✅ | ❌ |
| Create Firefox Sources ZIP | ✅ | ❌ | ❌ |
| First-class TypeScript support | ✅ | ✅ | ✅ |
| Entrypoint discovery | File based | File based | |
| Inline entrypoint config | ✅ | ✅ | Manifest based |
| Auto-imports | ✅ | ❌ | ❌ |
| Supports all frontend frameworks | ✅ | 🟡 Only React, Vue, and Svelte | ✅ |
| Framework specific entrypoints (like `Popup.tsx`) | 🟡 `.html` `.ts` `.tsx` | ✅ `.html` `.ts` `.tsx` `.vue` `.svelte` | ❌ |
| Automated publishing | | ✅ | ❌ |
| Remote Code Bundling (Google Analytics) | ✅ | ✅ | ❌ |
| <strong style="opacity: 50%">Dev Mode</strong> | | |
| `.env` Files | ✅ | ✅ |
| Opens browser and install extension | ✅ | ❌ |
| HMR for UIs | ✅ | 🟡 React only |
| Reload HTML Files on Change | ✅ | 🟡 Reloads entire extension |
| Reload Content Scripts on Change | ✅ | 🟡 Reloads entire extension |
| Reload Background on Change | 🟡 Reloads entire extension | 🟡 Reloads entire extension |
| <strong style="opacity: 50%">Built-in Utils</strong> | | |
| Storage | ✅ | |
| Messaging | 🟡 Coming soon | ✅ |
| Content Script UI | | |
| `.env` Files | ✅ | ✅ | ✅ |
| Opens browser and install extension | ✅ | ❌ | ❌ |
| HMR for UIs | ✅ | 🟡 React only | ✅ |
| Reload HTML Files on Change | ✅ | 🟡 Reloads entire extension | ✅ |
| Reload Content Scripts on Change | ✅ | 🟡 Reloads entire extension | ✅ |
| Reload Background on Change | 🟡 Reloads entire extension | 🟡 Reloads entire extension | 🟡 Reloads entire extension |
| Respects Content Script `run_at` | ✅ | | ❌ ESM-style loaders run asynchronously |
| <strong style="opacity: 50%">Built-in Utils</strong> | | | |
| Storage | | ✅ | ❌ |
| Messaging | | | |
| Content Script UI | ✅ | ✅ | ❌ |
## Dev Mode
## Why use WXT?
WXT's main goal is improving the development experience (DX) of creating web extensions. There are two things WXT does differently:
WXT's main goal is improving the development experience (DX) of creating web extensions, while not sacrificing support. There are two things WXT does differently:
1. Automatically opens a browser with the extension installed when starting development
2. Reload each part of the extension individually rather than reloading the entire extension
Opening a browser automatically makes it super easy to start and stop development without having to manually load the extension in your browser.
Reloading each part of the extension individually improves your iteration speed while developing UIs. This is because reloading the entire extension on every change will close the popup and any tabs open to an extension page, like options. If you save a file associated with a UI and a content script while working on the UI, it will randomly close because it needed to reload the extension when the content script changed. This interupts your development flow and is really annoying.
Reloading each part of the extension individually improves your iteration speed while developing UIs. This is because reloading the entire extension on every change will close the popup and any tabs open to an extension page, like options. If you save a file associated with a UI and a content script while working on the UI, it will randomly close because it needed to reload the extension when the content script changed. This interrupts your development flow and is really annoying.
WXT solves this problem by reloading HTML pages and content scripts individually (when possible) to keep your UIs open while you develop them. This is a MV3 feature, so if you're developing a MV2 extension, you'll get the same dev experience as Plasmo.
+1 -1
View File
@@ -1,6 +1,6 @@
# Configuration
WXT's behavior can be configured via the `wxt.config.ts` file. In this file, you can add Vite plugins, change the directory strucutre of your project, and set fields on your `manifest.json`.
WXT's behavior can be configured via the `wxt.config.ts` file. In this file, you can add Vite plugins, change the directory structure of your project, and set fields on your `manifest.json`.
## Config File
+12 -19
View File
@@ -26,7 +26,6 @@ export default defineContentScript({
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
// Append children to the container
const app = document.createElement('p');
@@ -52,7 +51,6 @@ export default defineContentScript({
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);
@@ -82,7 +80,6 @@ export default defineContentScript({
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);
@@ -111,7 +108,6 @@ export default defineContentScript({
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
// Create the Svelte app inside the UI container
const app = new App({
@@ -141,7 +137,6 @@ export default defineContentScript({
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);
@@ -162,7 +157,7 @@ export default defineContentScript({
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.
You can control how CSS is injected for an integrated content script UI with the [`cssInjectionMode`](/api/wxt/interfaces/BaseContentScriptEntrypointOptions#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
@@ -173,7 +168,7 @@ WXT's [`createShadowRootUi`](/api/wxt/client/functions/createShadowRootUi) abstr
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`
2. Set [`cssInjectionMode: "ui"`](/api/wxt/interfaces/BaseContentScriptEntrypointOptions#cssinjectionmode) inside `defineContentScript`
3. Define your UI with `createShadowRootUi()`
4. Mount the UI so it is visible to users
@@ -192,7 +187,6 @@ export default defineContentScript({
// 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
@@ -223,7 +217,6 @@ export default defineContentScript({
// 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
@@ -233,7 +226,7 @@ export default defineContentScript({
},
onRemove: (app) => {
// Unmount the app when the UI is removed
app.unmount();
app?.unmount();
},
});
@@ -259,16 +252,19 @@ export default defineContentScript({
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
// Container is a body, and React warns when creating a root on the body, so create a wrapper div
const app = document.createElement('div');
container.append(app);
// Create a root on the UI container and render a component
const root = ReactDOM.createRoot(container);
const root = ReactDOM.createRoot(app);
root.render(<App />);
return root;
},
onRemove: (root) => {
// Unmount the root when the UI is removed
root.unmount();
root?.unmount();
},
});
@@ -293,7 +289,6 @@ export default defineContentScript({
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({
@@ -303,7 +298,7 @@ export default defineContentScript({
},
onRemove: (app) => {
// Destroy the app when the UI is removed
app.$destroy();
app?.$destroy();
},
});
@@ -328,14 +323,13 @@ export default defineContentScript({
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();
unmount?.();
},
});
@@ -374,7 +368,7 @@ WXT provides a helper function, [`createIframeUi`](/api/wxt/client/functions/cre
</body>
</html>
```
1. Add the page to the manifest's `web_accessible_resouces`
1. Add the page to the manifest's `web_accessible_resources`
```ts
// wxt.config.ts
export default defineConfig({
@@ -398,7 +392,6 @@ WXT provides a helper function, [`createIframeUi`](/api/wxt/client/functions/cre
// 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
+20 -2
View File
@@ -13,6 +13,24 @@ WXT's main goal is providing the best DX it possibly can. When running your exte
| `wxt.config.ts` | | | | 🟡 See [#10](https://github.com/wxt-dev/wxt/issues/10) |
| `web-ext.config.ts` | | | | 🟡 See [#10](https://github.com/wxt-dev/wxt/issues/10) |
## Dev Mode vs Production Builds
There are some notable differences between the development and production versions of an extension. During development:
1. **Content scripts are not listed in the `manifest.json`** when targeting MV3. Instead, the [`scripting`](https://developer.chrome.com/docs/extensions/reference/api/scripting) permission is used to register content scripts at runtime so they can be reloaded individually.
To get the list of content scripts during development, run the following in the background's console:
```ts
await chrome.scripting.getRegisteredContentScripts();
```
2. **The CSP is modified to allow loading scripts from the dev server**. Make sure you're using Chrome v110 or above for HMR to work.
3. If you don't include a background script/service worker, one will be created to perform various tasks in dev mode, mostly related to reloading different parts of the extension on change.
For production builds, none of the above modifications will be applied, and you're extension/manifest will only include what you have defined.
## Configure Browser Startup
WXT uses [`web-ext` by Mozilla](https://github.com/mozilla/web-ext) to automatically open a browser with the extension installed. You can configure the runner's behavior via the [`runner`](/api/wxt/interfaces/InlineConfig#runner) option, or in a separate gitignored file, `web-ext.config.ts`.
@@ -41,7 +59,7 @@ 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.
`web-ext`'s browser discovery is very limited. 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
// ~/web-ext.config.ts
@@ -83,6 +101,6 @@ Normally, to manually reload an extension, you have to visit `chrome://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`.
:::note
:::info
This shortcut is only available during development, and is not be added to your extension when running `wxt build` or `wxt-zip`.
:::
+4 -4
View File
@@ -58,7 +58,7 @@ See [`/entrypoints` folder](/entrypoints/background) documentation for a full li
## Entrypoint Options
Some entrypoints, like content scripts, actions, or the background, can recieve additional options.
Some entrypoints, like content scripts, actions, or the background, can receive additional options.
In HTML files, options are listed as `meta` tags:
@@ -114,11 +114,11 @@ WXT 0.14.1
[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.
This throws an error because WXT needs to import each entrypoint during the build process to extract its definition (containing the `match`, `runAt`, `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.
When importing your entrypoint to get its definition, the file is imported in a **_node environment_**, and doesn't have access to the `window`, `chrome`, or `browser` globals a web extension usually 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.
:::
@@ -126,7 +126,7 @@ When importing your entrypoint to get its definition, the file is imported in a
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:
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 literal values when defining an entrypoint instead of importing them:
```ts
import { GOOGLE_MATCHES } from '~/utils/match-patterns'; // [!code --]
+1 -1
View File
@@ -30,7 +30,7 @@ export default defineBackground({
```
:::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.
Only MV3 support ESM background scripts/service workers. When targeting MV2, the `type` option is ignored and the background is always bundled into a single file as IIFE.
:::
## Content Scripts
+2 -2
View File
@@ -133,7 +133,7 @@ The dev command will build the extension for development, open the browser, and
When running the dev command, WXT will make several changes to your `manifest.json` to improve your development experience:
- If missing, add a background script/service worker to enable fast reloads
- Add serveral `permissions` and `host_permissions` to enable HMR and fast reloads
- Add several `permissions` and `host_permissions` to enable HMR and fast reloads
- Modify the CSP to allow connections with the dev server
- Remove `content_scripts` and register them at runtime so they can be easily reloaded when you save a file
@@ -147,4 +147,4 @@ 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
- Checkout [example projects](https://github.com/wxt-dev/wxt-examples) to see how to perform common tasks with WXT
+1 -1
View File
@@ -20,7 +20,7 @@ WXT is an opinionated framework. This helps keep projects consistent and easy to
- **Generated manifest**: Based on your project's file structure
- **Entrypoint configuration**: Configure entrypoints from the same file they're declare in
- **Type-safety is a priority**: Out-of-the-box TypeScript support with improved browser API typing
- **Simple output file structure**: Ouptut file paths minimize the path at runtime
- **Simple output file structure**: Output file paths minimize the path at runtime
## Development
+49 -5
View File
@@ -1,3 +1,7 @@
---
outline: deep
---
# Manifest.json
The manifest.json is generated at build-time based on files in the `entrypoints/` directory and `wxt.config.ts`.
@@ -20,11 +24,11 @@ export default defineConfig({
});
```
## `name`
### `name`
If not provided via the `manifest` config, the [manifest's `name`](https://developer.chrome.com/docs/extensions/mv3/manifest/name/) defaults to your package.json's `name` property.
## `version` and `version_name`
### `version` and `version_name`
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`.
@@ -33,7 +37,7 @@ The [manifest's `version` and `version_name`](https://developer.chrome.com/docs/
If a version is not found, a warning is logged and the version defaults to `"0.0.0"`.
### Example
#### Example
```json
// package.json
@@ -50,7 +54,7 @@ If a version is not found, a warning is logged and the version defaults to `"0.0
}
```
## `icons`
### `icons`
By default, WXT will discover icons in your [`public` directory](/guide/assets#public-directory) and use them for the [manifest's `icons`](https://developer.chrome.com/docs/extensions/mv3/manifest/icons/).
@@ -83,7 +87,7 @@ export default defineConfig({
});
```
## `permissions`
### `permissions`
[Permissions](https://developer.chrome.com/docs/extensions/reference/permissions/) must be listed in the manifest config.
@@ -125,3 +129,43 @@ export default defineConfig({
See the official localization examples for more details:
<ExampleList tag="i18n" />
## Per-Browser Configuration
The `manifest` field can be a function. If you are building and extension for multiple browsers, and need to modify the manifest per browser, using a function instead of an object is very useful.
```ts
export default defineConfig({
manifest: ({ browser, manifestVersion, mode, command }) => {
return {
// Your manifest
};
},
});
```
:::info
The first argument is of type `ConfigEnv`. See the [API reference](/api/wxt/interfaces/ConfigEnv) for info about each property.
:::
For example, say you use OAuth, and you need to provide a different `oauth.client_id` for each browser:
```ts
const clientIds = {
chrome: '<your-chrome-client-id>',
edge: '<your-edge-client-id>',
firefox: '<your-firefox-client-id>',
opera: '<your-opera-client-id>',
};
export default defineConfig({
manifest: ({ browser }) => ({
oauth: {
client_id: clientIds[browser],
scopes: [
// ...
],
},
}),
});
```
+1 -1
View File
@@ -32,7 +32,7 @@ Every project is different, so there's no one-solution-fits-all to migrating you
## Popular Tools/Frameworks
Here's specific steps for other popuplar frameworks/build tools.
Here's specific steps for other popular frameworks/build tools.
### `vite-plugin-web-extension`
+3 -3
View File
@@ -1,6 +1,6 @@
# Multiple Browsers
You can build an extension for any combination of browser and manifest verison. Different browsers and manifest versions support different APIs and entrypoints, so be sure to check that your extension functions as expected for each target.
You can build an extension for any combination of browser and manifest version. Different browsers and manifest versions support different APIs and entrypoints, so be sure to check that your extension functions as expected for each target.
Separate build targets are written to their own output directories:
@@ -45,7 +45,7 @@ wxt --mv2
wxt build --mv2
```
When the `-b --browser` flag is not passed, it defaults to `chrome`. So here, we're targetting MV2 for Chrome.
When the `-b --browser` flag is not passed, it defaults to `chrome`. So here, we're targeting MV2 for Chrome.
## Customizing Entrypoints
@@ -116,7 +116,7 @@ Only `defineBackground` and `defineContentScript` support per-browser options ri
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.MANIFEST_VERSION`: A number, either `2` or `3`, depending on the manifest version targeted
- `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"`
+23 -1
View File
@@ -64,7 +64,7 @@ See the [Firefox Addon Store](#firefox-addon-store) section for more details abo
## GitHub Action
Here's an example of a GitHub Action to automate submiting new versions of your extension for review. Ensure that you've added all required secrets used in the workflow to the repo's settings.
Here's an example of a GitHub Action to automate submitting new versions of your extension for review. Ensure that you've added all required secrets used in the workflow to the repo's settings.
```yml
# TODO
@@ -136,6 +136,28 @@ See Issue [#377](https://github.com/wxt-dev/wxt/issues/377) for more details.
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.
#### Private Packages
If you use private packages and you don't want to provide your auth token to the Firefox team during the review process, you can use `zip.downloadPackages` to download any private packages and include them in the zip.
```ts
// wxt.config.ts
export default defineConfig({
zip: {
downloadPackages: [
'@mycompany/some-package',
//...
],
},
});
```
Depending on your package manager, the `package.json` in the sources zip will be modified to use the downloaded dependencies via the `overrides` or `resolutions` field.
:::warning
WXT uses the command `npm pack <package-name>` to download the package. That means regardless of your package manager, you need to properly setup a `.npmrc` file. NPM and PNPM both respect `.npmrc` files, but Yarn and Bun have their own ways of authorizing private registries, so you'll need to add an `.npmrc` file.
:::
### Safari
> 🚧 Not supported yet
+14 -2
View File
@@ -1,3 +1,7 @@
---
outline: deep
---
# Storage API
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:
@@ -35,6 +39,8 @@ await storage.watch<number>(
await storage.getMeta<{ v: number }>('local:installDate');
```
For a full list of methods available, see the [API reference](/api/wxt/storage/interfaces/WxtStorage).
## Watchers
To listen for storage changes, use the `storage.watch` function. It lets you setup a listener for a single key:
@@ -58,7 +64,7 @@ unwatch();
`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:
[Other than versioning](#versioning), you are responsible for managing a field's metadata:
```ts
await Promise.all([
@@ -116,7 +122,9 @@ const unwatch = showChangelogOnUpdate.watch(() => {
});
```
### Versioning and Migrations
For a full list of properties and methods available, see the [API reference](/api/wxt/storage/interfaces/WxtStorageItem).
### Versioning
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.
@@ -252,3 +260,7 @@ export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV2[]>( // [!code
```
:::
### Running Migrations
As soon as `storage.defineItem` is called, WXT checks if migrations need to be ran, and if so, runs them. Calls to get or update the storage item's value or metadata (`getValue`, `setValue`, `removeValue`, `getMeta`, etc) will automatically wait for the migration process to finish before actually reading or writing values.
+10 -10
View File
@@ -6,7 +6,7 @@ title: Next-gen Web Extension Framework
hero:
name: WXT
text: Next-gen Web Extension Framework
tagline: An open source tool that makes Chrome Extension devlopment faster than ever before.
tagline: An open source tool that makes Chrome Extension development faster than ever before.
image:
src: /hero-logo.svg
alt: WXT
@@ -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.
@@ -64,10 +62,12 @@ features:
linkText: See templates
- 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.'
details: Tools for analyzing the final extension bundle and minimizing your extension's size.
- 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">
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { TestProject } from '../utils';
import { resetBundleIncrement } from '~/core/builders/vite/plugins';
describe('Analysis', () => {
beforeEach(() => {
resetBundleIncrement();
});
it('should output 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,
},
});
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);
});
});
+82 -4
View File
@@ -7,7 +7,7 @@ describe('Auto Imports', () => {
const project = new TestProject();
project.addFile('entrypoints/popup.html', `<html></html>`);
await project.build();
await project.prepare();
expect(await project.serializeFile('.wxt/types/imports.d.ts'))
.toMatchInlineSnapshot(`
@@ -38,7 +38,7 @@ describe('Auto Imports', () => {
const project = new TestProject();
project.addFile('entrypoints/popup.html', `<html></html>`);
await project.build();
await project.prepare();
expect(await project.serializeFile('.wxt/wxt.d.ts'))
.toMatchInlineSnapshot(`
@@ -63,7 +63,7 @@ describe('Auto Imports', () => {
});
project.addFile('entrypoints/popup.html', `<html></html>`);
await project.build();
await project.prepare();
expect(await project.fileExists('.wxt/types/imports.d.ts')).toBe(false);
});
@@ -75,7 +75,7 @@ describe('Auto Imports', () => {
});
project.addFile('entrypoints/popup.html', `<html></html>`);
await project.build();
await project.prepare();
expect(
await project.serializeFile('.wxt/wxt.d.ts'),
@@ -93,4 +93,82 @@ describe('Auto Imports', () => {
);
});
});
describe('eslintrc', () => {
it('should output the globals list for ESLint to consume', async () => {
const project = new TestProject();
project.addFile('entrypoints/popup.html', `<html></html>`);
await project.prepare({
imports: {
eslintrc: {
enabled: true,
},
},
});
expect(await project.serializeFile('.wxt/eslintrc-auto-import.json'))
.toMatchInlineSnapshot(`
".wxt/eslintrc-auto-import.json
----------------------------------------
{
"globals": {
"ContentScriptContext": true,
"InvalidMatchPattern": true,
"MatchPattern": true,
"browser": true,
"createIframeUi": true,
"createIntegratedUi": true,
"createShadowRootUi": true,
"defineBackground": true,
"defineConfig": true,
"defineContentScript": true,
"defineUnlistedScript": true,
"fakeBrowser": true,
"storage": true
}
}
"
`);
});
it('should allow customizing the output', async () => {
const project = new TestProject();
project.addFile('entrypoints/popup.html', `<html></html>`);
await project.prepare({
imports: {
eslintrc: {
enabled: true,
filePath: project.resolvePath('example.json'),
globalsPropValue: 'readonly',
},
},
});
expect(await project.serializeFile('example.json'))
.toMatchInlineSnapshot(`
"example.json
----------------------------------------
{
"globals": {
"ContentScriptContext": "readonly",
"InvalidMatchPattern": "readonly",
"MatchPattern": "readonly",
"browser": "readonly",
"createIframeUi": "readonly",
"createIntegratedUi": "readonly",
"createShadowRootUi": "readonly",
"defineBackground": "readonly",
"defineConfig": "readonly",
"defineContentScript": "readonly",
"defineUnlistedScript": "readonly",
"fakeBrowser": "readonly",
"storage": "readonly"
}
}
"
`);
});
});
});
+1
View File
@@ -25,6 +25,7 @@ describe('Init command', () => {
"assets/vue.svg",
"components/HelloWorld.vue",
"entrypoints/background.ts",
"entrypoints/content.ts",
"entrypoints/popup/App.vue",
"entrypoints/popup/index.html",
"entrypoints/popup/main.ts",
+36 -36
View File
@@ -270,43 +270,43 @@ describe('Output Directory Structure', () => {
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");
".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;
}
});
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"
);
const definition = defineBackground({
type: "module",
main() {
logHello("background");
}
});
chrome;
function print(method, ...args) {
return;
}
} catch (err) {
logger.error("The background crashed on startup!");
throw err;
}
"
`);
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 synchronous"
);
}
} catch (err) {
logger.error("The background crashed on startup!");
throw err;
}
"
`);
});
it('should generate IIFE background script when type=undefined', async () => {
@@ -381,7 +381,7 @@ describe('Output Directory Structure', () => {
const res = definition.main();
if (res instanceof Promise) {
console.warn(
"The background's main() function return a promise, but it must be synchonous"
"The background's main() function return a promise, but it must be synchronous"
);
}
} catch (err) {
+7 -7
View File
@@ -6,7 +6,7 @@ describe('TypeScript Project', () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
await project.build();
await project.prepare();
const output = await project.serializeFile('.wxt/types/globals.d.ts');
expect(output).toMatchInlineSnapshot(`
@@ -38,7 +38,7 @@ describe('TypeScript Project', () => {
project.addFile('entrypoints/options.html');
project.addFile('entrypoints/sandbox.html');
await project.build();
await project.prepare();
const output = await project.serializeFile('.wxt/types/paths.d.ts');
expect(output).toMatchInlineSnapshot(`
@@ -103,7 +103,7 @@ describe('TypeScript Project', () => {
},
});
await project.build();
await project.prepare();
const output = await project.serializeFile('.wxt/types/i18n.d.ts');
expect(output).toMatchInlineSnapshot(`
@@ -225,7 +225,7 @@ describe('TypeScript Project', () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
await project.build();
await project.prepare();
const output = await project.serializeFile('.wxt/wxt.d.ts');
expect(output).toMatchInlineSnapshot(`
@@ -245,7 +245,7 @@ describe('TypeScript Project', () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
await project.build();
await project.prepare();
const output = await project.serializeFile('.wxt/tsconfig.json');
expect(output).toMatchInlineSnapshot(`
@@ -289,7 +289,7 @@ describe('TypeScript Project', () => {
srcDir: 'src',
});
await project.build();
await project.prepare();
const output = await project.serializeFile('.wxt/tsconfig.json');
expect(output).toMatchInlineSnapshot(`
@@ -337,7 +337,7 @@ describe('TypeScript Project', () => {
},
});
await project.build();
await project.prepare();
const output = await project.serializeFile('.wxt/tsconfig.json');
expect(output).toMatchInlineSnapshot(`
+25
View File
@@ -107,4 +107,29 @@ describe('User Config', () => {
});
expect(withoutPolyfill).not.toBe(withPolyfill);
});
it('should respect changing config files', async () => {
const project = new TestProject();
project.addFile(
'src/entrypoints/background.ts',
`export default defineBackground(
() => console.log('Hello background'),
);`,
);
project.addFile(
'test.config.ts',
`import { defineConfig } from 'wxt';
export default defineConfig({
outDir: ".custom-output",
srcDir: "src",
});`,
);
await project.build({ configFile: 'test.config.ts' });
expect(
await project.fileExists('.custom-output/chrome-mv3/background.js'),
).toBe(true);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { TestProject } from '../utils';
import extract from 'extract-zip';
import { execaCommand } from 'execa';
import { readFile, writeFile } from 'fs-extra';
process.env.WXT_PNPM_IGNORE_WORKSPACE = 'true';
describe('Zipping', () => {
it('should download packages and produce a valid build when zipping sources', async () => {
const project = new TestProject({
name: 'test',
version: '1.0.0',
dependencies: {
flatten: '1.0.3',
},
});
project.addFile(
'entrypoints/background.ts',
'export default defineBackground(() => {});',
);
const unzipDir = project.resolvePath('.output/test-1.0.0-sources');
const sourcesZip = project.resolvePath('.output/test-1.0.0-sources.zip');
await project.zip({
browser: 'firefox',
zip: { downloadPackages: ['flatten'] },
});
expect(await project.fileExists('.output/')).toBe(true);
await extract(sourcesZip, { dir: unzipDir });
// Update package json wxt path
const packageJsonPath = project.resolvePath(unzipDir, 'package.json');
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8'));
packageJson.dependencies.wxt = '../../../../..';
await writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2),
'utf-8',
);
// Build zipped extension
await expect(
execaCommand('pnpm i --ignore-workspace --frozen-lockfile false', {
cwd: unzipDir,
}),
).resolves.toMatchObject({ exitCode: 0 });
await expect(
execaCommand('pnpm wxt build -b firefox', { cwd: unzipDir }),
).resolves.toMatchObject({ exitCode: 0 });
await expect(project.fileExists(unzipDir, '.output')).resolves.toBe(true);
expect(
await project.serializeFile(
project.resolvePath(unzipDir, 'package.json'),
),
).toMatchInlineSnapshot(`
".output/test-1.0.0-sources/package.json
----------------------------------------
{
"name": "test",
"description": "Example description",
"version": "1.0.0",
"dependencies": {
"wxt": "../../../../..",
"flatten": "1.0.3"
},
"resolutions": {
"flatten@1.0.3": "file://./.wxt/local_modules/flatten-1.0.3.tgz"
}
}"
`);
});
});
+15 -8
View File
@@ -92,12 +92,19 @@ export class TestProject {
return server;
}
/**
* Call `path.resolve` relative to the project's root directory.
*/
resolvePath(...path: string[]): string {
return resolve(this.root, ...path);
}
private async writeProjectToDisk() {
if (this.config == null) this.setConfigFileConfig();
for (const file of this.files) {
const [name, content] = file;
const filePath = resolve(this.root, name);
const filePath = this.resolvePath(name);
const fileDir = dirname(filePath);
await fs.ensureDir(fileDir);
await fs.writeFile(filePath, content ?? '', 'utf-8');
@@ -140,13 +147,13 @@ export class TestProject {
ignoreContentsOfFilenames?: string[],
): Promise<string> {
const outputFiles = await glob('**/*', {
cwd: resolve(this.root, dir),
cwd: this.resolvePath(dir),
ignore: ['**/node_modules', '**/.output'],
});
outputFiles.sort();
const fileContents = [];
for (const file of outputFiles) {
const path = resolve(this.root, dir, file);
const path = this.resolvePath(dir, file);
const isContentIgnored = !!ignoreContentsOfFilenames?.find(
(ignoredFile) => normalizePath(path).endsWith(ignoredFile),
);
@@ -156,25 +163,25 @@ export class TestProject {
}
/**
* @param path An abosolute path to a file or a path relative to the root.
* @param path An absolute path to a file or a path relative to the root.
* @param ignoreContents An optional boolean that, when true, causes this function to not print
* the file contents.
*/
async serializeFile(path: string, ignoreContents?: boolean): Promise<string> {
const absolutePath = resolve(this.root, path);
const absolutePath = this.resolvePath(path);
return [
normalizePath(relative(this.root, absolutePath)),
ignoreContents ? '<contents-ignored>' : await fs.readFile(absolutePath),
].join(`\n${''.padEnd(40, '-')}\n`);
}
fileExists(path: string): Promise<boolean> {
return fs.exists(resolve(this.root, path));
fileExists(...path: string[]): Promise<boolean> {
return fs.exists(this.resolvePath(...path));
}
async getOutputManifest(
path: string = '.output/chrome-mv3/manifest.json',
): Promise<any> {
return await fs.readJson(resolve(this.root, path));
return await fs.readJson(this.resolvePath(path));
}
}
+13 -9
View File
@@ -1,7 +1,7 @@
{
"name": "wxt",
"type": "module",
"version": "0.16.4",
"version": "0.17.5",
"description": "Next gen framework for developing web extensions",
"engines": {
"node": ">=18",
@@ -30,7 +30,10 @@
"bin",
"dist"
],
"bin": "./bin/wxt.mjs",
"bin": {
"wxt": "./bin/wxt.mjs",
"wxt-publish-extension": "./bin/wxt-publish-extension.cjs"
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -103,6 +106,7 @@
"sync-releases": "pnpx changelogen@latest gh release"
},
"dependencies": {
"@aklinker1/rollup-plugin-visualizer": "5.12.0",
"@types/webextension-polyfill": "^0.10.5",
"@webext-core/fake-browser": "^1.3.1",
"@webext-core/isolated-element": "^1.1.1",
@@ -124,21 +128,20 @@
"is-wsl": "^3.0.0",
"jiti": "^1.21.0",
"json5": "^2.2.3",
"jszip": "^3.10.1",
"linkedom": "^0.16.1",
"manage-path": "^2.0.0",
"minimatch": "^9.0.3",
"natural-compare": "^1.4.0",
"normalize-path": "^3.0.0",
"nypm": "^0.3.6",
"ora": "^7.0.1",
"picocolors": "^1.0.0",
"prompts": "^2.4.2",
"publish-browser-extension": "^2.1.1",
"rollup-plugin-visualizer": "^5.9.2",
"publish-browser-extension": "^2.1.3",
"unimport": "^3.4.0",
"vite": "^5.0.12",
"vite": "^5.1.3",
"web-ext-run": "^0.2.0",
"webextension-polyfill": "^0.10.0",
"zip-dir": "^2.0.0"
"webextension-polyfill": "^0.10.0"
},
"devDependencies": {
"@faker-js/faker": "^8.3.1",
@@ -150,6 +153,7 @@
"@types/prompts": "^2.4.9",
"@vitest/coverage-v8": "^1.0.1",
"execa": "^8.0.1",
"extract-zip": "^2.0.1",
"happy-dom": "^13.3.8",
"lint-staged": "^15.2.0",
"lodash.merge": "^4.6.2",
@@ -165,7 +169,7 @@
"typedoc-vitepress-theme": "1.0.0-next.3",
"typescript": "^5.3.2",
"vitepress": "1.0.0-rc.34",
"vitest": "^1.1.0",
"vitest": "^1.2.2",
"vitest-mock-extended": "^1.3.1",
"vue": "^3.3.10"
},
+125 -101
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@aklinker1/rollup-plugin-visualizer':
specifier: 5.12.0
version: 5.12.0
'@types/webextension-polyfill':
specifier: ^0.10.5
version: 0.10.7
@@ -71,12 +74,12 @@ importers:
json5:
specifier: ^2.2.3
version: 2.2.3
jszip:
specifier: ^3.10.1
version: 3.10.1
linkedom:
specifier: ^0.16.1
version: 0.16.1
manage-path:
specifier: ^2.0.0
version: 2.0.0
minimatch:
specifier: ^9.0.3
version: 9.0.3
@@ -86,6 +89,9 @@ importers:
normalize-path:
specifier: ^3.0.0
version: 3.0.0
nypm:
specifier: ^0.3.6
version: 0.3.6
ora:
specifier: ^7.0.1
version: 7.0.1
@@ -96,26 +102,20 @@ importers:
specifier: ^2.4.2
version: 2.4.2
publish-browser-extension:
specifier: ^2.1.1
version: 2.1.1
rollup-plugin-visualizer:
specifier: ^5.9.2
version: 5.12.0
specifier: ^2.1.3
version: 2.1.3
unimport:
specifier: ^3.4.0
version: 3.4.0
vite:
specifier: ^5.0.12
version: 5.0.12(@types/node@20.10.3)(sass@1.69.5)
specifier: ^5.1.3
version: 5.1.3(@types/node@20.10.3)(sass@1.69.5)
web-ext-run:
specifier: ^0.2.0
version: 0.2.0
webextension-polyfill:
specifier: ^0.10.0
version: 0.10.0
zip-dir:
specifier: ^2.0.0
version: 2.0.0
devDependencies:
'@faker-js/faker':
specifier: ^8.3.1
@@ -140,10 +140,13 @@ importers:
version: 2.4.9
'@vitest/coverage-v8':
specifier: ^1.0.1
version: 1.2.2(vitest@1.1.0)
version: 1.2.2(vitest@1.2.2)
execa:
specifier: ^8.0.1
version: 8.0.1
extract-zip:
specifier: ^2.0.1
version: 2.0.1
happy-dom:
specifier: ^13.3.8
version: 13.3.8
@@ -190,11 +193,11 @@ importers:
specifier: 1.0.0-rc.34
version: 1.0.0-rc.34(@types/node@20.10.3)(typescript@5.3.3)
vitest:
specifier: ^1.1.0
version: 1.1.0(@types/node@20.10.3)(happy-dom@13.3.8)(sass@1.69.5)
specifier: ^1.2.2
version: 1.2.2(@types/node@20.10.3)(happy-dom@13.3.8)(sass@1.69.5)
vitest-mock-extended:
specifier: ^1.3.1
version: 1.3.1(typescript@5.3.3)(vitest@1.1.0)
version: 1.3.1(typescript@5.3.3)(vitest@1.2.2)
vue:
specifier: ^3.3.10
version: 3.3.10(typescript@5.3.3)
@@ -208,8 +211,8 @@ importers:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
vitest:
specifier: ^1.1.0
version: 1.1.0(@types/node@20.10.3)(happy-dom@13.3.8)(sass@1.69.5)
specifier: ^1.2.2
version: 1.2.2(@types/node@20.10.3)(happy-dom@13.3.8)(sass@1.69.5)
devDependencies:
'@types/react':
specifier: ^18.2.34
@@ -226,6 +229,22 @@ importers:
packages:
/@aklinker1/rollup-plugin-visualizer@5.12.0:
resolution: {integrity: sha512-X24LvEGw6UFmy0lpGJDmXsMyBD58XmX1bbwsaMLhNoM+UMQfQ3b2RtC+nz4b/NoRK5r6QJSKJHBNVeUdwqybaQ==}
engines: {node: '>=14'}
hasBin: true
peerDependencies:
rollup: 2.x || 3.x || 4.x
peerDependenciesMeta:
rollup:
optional: true
dependencies:
open: 8.4.2
picomatch: 2.3.1
source-map: 0.7.4
yargs: 17.7.2
dev: false
/@algolia/autocomplete-core@1.9.3(algoliasearch@4.20.0):
resolution: {integrity: sha512-009HdfugtGCdC4JdXUbVJClA0q0zh24yyePn+KUGk3rP7j8FEe/m5Yo/z65gn6nP/cM39PxpzqKrL7A6fP6PPw==}
dependencies:
@@ -1058,7 +1077,6 @@ packages:
/@types/estree@1.0.4:
resolution: {integrity: sha512-2JwWnHK9H+wUZNorf2Zr6ves96WHoWDJIftkcxPKsS7Djta6Zu519LarhRNljPXkpsZR2ZMwNCPeW7omW07BJw==}
dev: false
/@types/fs-extra@11.0.4:
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
@@ -1165,21 +1183,20 @@ packages:
requiresBuild: true
dependencies:
'@types/node': 20.10.3
dev: false
optional: true
/@vitejs/plugin-vue@5.0.2(vite@5.0.12)(vue@3.4.3):
/@vitejs/plugin-vue@5.0.2(vite@5.1.3)(vue@3.4.3):
resolution: {integrity: sha512-kEjJHrLb5ePBvjD0SPZwJlw1QTRcjjCA9sB5VyfonoXVBxTS7TMnqL6EkLt1Eu61RDeiuZ/WN9Hf6PxXhPI2uA==}
engines: {node: ^18.0.0 || >=20.0.0}
peerDependencies:
vite: ^5.0.0
vue: ^3.2.25
dependencies:
vite: 5.0.12(@types/node@20.10.3)(sass@1.69.5)
vite: 5.1.3(@types/node@20.10.3)(sass@1.69.5)
vue: 3.4.3(typescript@5.3.3)
dev: true
/@vitest/coverage-v8@1.2.2(vitest@1.1.0):
/@vitest/coverage-v8@1.2.2(vitest@1.2.2):
resolution: {integrity: sha512-IHyKnDz18SFclIEEAHb9Y4Uxx0sPKC2VO1kdDCs1BF6Ip4S8rQprs971zIsooLUn7Afs71GRxWMWpkCGZpRMhw==}
peerDependencies:
vitest: ^1.0.0
@@ -1197,41 +1214,42 @@ packages:
std-env: 3.6.0
test-exclude: 6.0.0
v8-to-istanbul: 9.2.0
vitest: 1.1.0(@types/node@20.10.3)(happy-dom@13.3.8)(sass@1.69.5)
vitest: 1.2.2(@types/node@20.10.3)(happy-dom@13.3.8)(sass@1.69.5)
transitivePeerDependencies:
- supports-color
dev: true
/@vitest/expect@1.1.0:
resolution: {integrity: sha512-9IE2WWkcJo2BR9eqtY5MIo3TPmS50Pnwpm66A6neb2hvk/QSLfPXBz2qdiwUOQkwyFuuXEUj5380CbwfzW4+/w==}
/@vitest/expect@1.2.2:
resolution: {integrity: sha512-3jpcdPAD7LwHUUiT2pZTj2U82I2Tcgg2oVPvKxhn6mDI2On6tfvPQTjAI4628GUGDZrCm4Zna9iQHm5cEexOAg==}
dependencies:
'@vitest/spy': 1.1.0
'@vitest/utils': 1.1.0
'@vitest/spy': 1.2.2
'@vitest/utils': 1.2.2
chai: 4.3.10
/@vitest/runner@1.1.0:
resolution: {integrity: sha512-zdNLJ00pm5z/uhbWF6aeIJCGMSyTyWImy3Fcp9piRGvueERFlQFbUwCpzVce79OLm2UHk9iwaMSOaU9jVHgNVw==}
/@vitest/runner@1.2.2:
resolution: {integrity: sha512-JctG7QZ4LSDXr5CsUweFgcpEvrcxOV1Gft7uHrvkQ+fsAVylmWQvnaAr/HDp3LAH1fztGMQZugIheTWjaGzYIg==}
dependencies:
'@vitest/utils': 1.1.0
'@vitest/utils': 1.2.2
p-limit: 5.0.0
pathe: 1.1.1
/@vitest/snapshot@1.1.0:
resolution: {integrity: sha512-5O/wyZg09V5qmNmAlUgCBqflvn2ylgsWJRRuPrnHEfDNT6tQpQ8O1isNGgo+VxofISHqz961SG3iVvt3SPK/QQ==}
/@vitest/snapshot@1.2.2:
resolution: {integrity: sha512-SmGY4saEw1+bwE1th6S/cZmPxz/Q4JWsl7LvbQIky2tKE35US4gd0Mjzqfr84/4OD0tikGWaWdMja/nWL5NIPA==}
dependencies:
magic-string: 0.30.5
pathe: 1.1.1
pretty-format: 29.7.0
/@vitest/spy@1.1.0:
resolution: {integrity: sha512-sNOVSU/GE+7+P76qYo+VXdXhXffzWZcYIPQfmkiRxaNCSPiLANvQx5Mx6ZURJ/ndtEkUJEpvKLXqAYTKEY+lTg==}
/@vitest/spy@1.2.2:
resolution: {integrity: sha512-k9Gcahssw8d7X3pSLq3e3XEu/0L78mUkCjivUqCQeXJm9clfXR/Td8+AP+VC1O6fKPIDLcHDTAmBOINVuv6+7g==}
dependencies:
tinyspy: 2.2.0
/@vitest/utils@1.1.0:
resolution: {integrity: sha512-z+s510fKmYz4Y41XhNs3vcuFTFhcij2YF7F8VQfMEYAAUfqQh0Zfg7+w9xdgFGhPf3tX3TicAe+8BDITk6ampQ==}
/@vitest/utils@1.2.2:
resolution: {integrity: sha512-WKITBHLsBHlpjnDQahr+XK6RE7MiAsgrIkr0pGhQ9ygoxBfUeG0lUG5iLlzqjmKSlBv3+j5EGsriBzh+C3Tq9g==}
dependencies:
diff-sequences: 29.6.3
estree-walker: 3.0.3
loupe: 2.3.7
pretty-format: 29.7.0
@@ -1293,7 +1311,7 @@ packages:
'@vue/shared': 3.4.3
estree-walker: 2.0.2
magic-string: 0.30.5
postcss: 8.4.32
postcss: 8.4.35
source-map-js: 1.0.2
dev: true
@@ -1484,8 +1502,8 @@ packages:
resolution: {integrity: sha512-NY39ACqCxdKBmHgw361M9pfJma8e4AZo20w9AY+5ZjIj1W2dvXC8J31G5fjfOGbulW9w4WKpT8fPooi0mLkn9A==}
dev: false
/acorn-walk@8.3.0:
resolution: {integrity: sha512-FS7hV565M5l1R08MXqo8odwMTB02C2UqzB17RVgu9EyuYFBqJZ3/ZY97sQD5FewVu1UyDFc1yztUDrAwT0EypA==}
/acorn-walk@8.3.2:
resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==}
engines: {node: '>=0.4.0'}
/acorn@8.11.2:
@@ -1699,7 +1717,6 @@ packages:
/buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
dev: false
/buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -1866,6 +1883,12 @@ packages:
engines: {node: '>=8'}
dev: false
/citty@0.1.6:
resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
dependencies:
consola: 3.2.3
dev: false
/cli-boxes@3.0.0:
resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==}
engines: {node: '>=10'}
@@ -2247,7 +2270,6 @@ packages:
resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
dependencies:
once: 1.4.0
dev: false
/entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
@@ -2406,6 +2428,11 @@ packages:
/estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
/estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
dependencies:
'@types/estree': 1.0.4
/eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
@@ -2464,7 +2491,6 @@ packages:
'@types/yauzl': 2.10.3
transitivePeerDependencies:
- supports-color
dev: false
/fast-glob@3.3.2:
resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==}
@@ -2485,7 +2511,6 @@ packages:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
dependencies:
pend: 1.2.0
dev: false
/filesize@10.1.0:
resolution: {integrity: sha512-GTLKYyBSDz3nPhlLVPjPWZCnhkd9TrrRArNcy8Z+J2cqScB7h2McAzR6NBX6nYOoWafql0roY8hrocxnZBv9CQ==}
@@ -2642,7 +2667,6 @@ packages:
engines: {node: '>=8'}
dependencies:
pump: 3.0.0
dev: false
/get-stream@6.0.1:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
@@ -3539,10 +3563,6 @@ packages:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
dev: false
/manage-path@2.0.0:
resolution: {integrity: sha512-NJhyB+PJYTpxhxZJ3lecIGgh4kwIY2RAh44XvAz9UlqthlQwtPBf62uBVR8XaD8CRuSjQ6TnZH2lNJkbLPZM2A==}
dev: false
/mark.js@8.11.1:
resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==}
dev: true
@@ -3659,9 +3679,9 @@ packages:
resolution: {integrity: sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg==}
dependencies:
acorn: 8.11.2
pathe: 1.1.1
pathe: 1.1.2
pkg-types: 1.0.3
ufo: 1.3.1
ufo: 1.4.0
/moment@2.29.4:
resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
@@ -3834,6 +3854,17 @@ packages:
boolbase: 1.0.0
dev: false
/nypm@0.3.6:
resolution: {integrity: sha512-2CATJh3pd6CyNfU5VZM7qSwFu0ieyabkEdnogE30Obn1czrmOYiZ8DOZLe1yBdLKWoyD3Mcy2maUs+0MR3yVjQ==}
engines: {node: ^14.16.0 || >=16.10.0}
hasBin: true
dependencies:
citty: 0.1.6
execa: 8.0.1
pathe: 1.1.2
ufo: 1.4.0
dev: false
/object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -3862,7 +3893,7 @@ packages:
dependencies:
destr: 2.0.2
node-fetch-native: 1.4.1
ufo: 1.3.1
ufo: 1.4.0
dev: false
/ohash@1.1.3:
@@ -4039,12 +4070,14 @@ packages:
/pathe@1.1.1:
resolution: {integrity: sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==}
/pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
/pathval@1.1.1:
resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==}
/pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
dev: false
/perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
@@ -4084,7 +4117,7 @@ packages:
dependencies:
jsonc-parser: 3.2.0
mlly: 1.4.2
pathe: 1.1.1
pathe: 1.1.2
/postcss-load-config@4.0.1:
resolution: {integrity: sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==}
@@ -4109,6 +4142,15 @@ packages:
nanoid: 3.3.7
picocolors: 1.0.0
source-map-js: 1.0.2
dev: true
/postcss@8.4.35:
resolution: {integrity: sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==}
engines: {node: ^10 || ^12 || >=14}
dependencies:
nanoid: 3.3.7
picocolors: 1.0.0
source-map-js: 1.0.2
/preact@10.18.1:
resolution: {integrity: sha512-mKUD7RRkQQM6s7Rkmi7IFkoEHjuFqRQUaXamO61E6Nn7vqF/bo7EZCmSyrUnp2UWHw0O7XjZ2eeXis+m7tf4lg==}
@@ -4161,9 +4203,9 @@ packages:
sade: 1.8.1
dev: true
/publish-browser-extension@2.1.1:
resolution: {integrity: sha512-WqCnhHXbmiYn4vIbgcyKWVHiOvCoPjjpt3X+hfnU8rlOozU43TURpHUWZlQ0W49LhvJvBRsBfGRQxtRMGhqsxw==}
engines: {node: '18', pnpm: '8'}
/publish-browser-extension@2.1.3:
resolution: {integrity: sha512-qisnXUUwjvu5kMvObfG7UQ9rPU3t0XfbKdCdCrwAXaLUySdC25nlM4gxi+CBvL7LiwvK494GJyEK/weQBhIyTQ==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
dependencies:
cac: 6.7.14
@@ -4190,7 +4232,6 @@ packages:
dependencies:
end-of-stream: 1.4.4
once: 1.4.0
dev: false
/punycode@2.3.0:
resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==}
@@ -4377,22 +4418,6 @@ packages:
glob: 7.1.6
dev: false
/rollup-plugin-visualizer@5.12.0:
resolution: {integrity: sha512-8/NU9jXcHRs7Nnj07PF2o4gjxmm9lXIrZ8r175bT9dK8qoLlvKTwRMArRCMgpMGlq8CTLugRvEmyMeMXIU2pNQ==}
engines: {node: '>=14'}
hasBin: true
peerDependencies:
rollup: 2.x || 3.x || 4.x
peerDependenciesMeta:
rollup:
optional: true
dependencies:
open: 8.4.2
picomatch: 2.3.1
source-map: 0.7.4
yargs: 17.7.2
dev: false
/rollup@4.6.1:
resolution: {integrity: sha512-jZHaZotEHQaHLgKr8JnQiDT1rmatjgKlMekyksz+yk9jt/8z9quNjnKNRoaM0wd9DC2QKXjmWWuDYtM3jfF8pQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -4885,8 +4910,8 @@ packages:
/tinybench@2.5.1:
resolution: {integrity: sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==}
/tinypool@0.8.1:
resolution: {integrity: sha512-zBTCK0cCgRROxvs9c0CGK838sPkeokNGdQVUUwHAbynHFlmyJYj825f/oRs528HaIJ97lo0pLIlDUzwN+IorWg==}
/tinypool@0.8.2:
resolution: {integrity: sha512-SUszKYe5wgsxnNOVlBYO6IC+8VGWdVGZWAqUxp3UErNBtptZvWbwyUOyzNL59zigz2rCA92QiL3wvG+JDSdJdQ==}
engines: {node: '>=14.0.0'}
/tinyspy@2.2.0:
@@ -5065,8 +5090,8 @@ packages:
hasBin: true
dev: true
/ufo@1.3.1:
resolution: {integrity: sha512-uY/99gMLIOlJPwATcMVYfqDSxUR9//AUcgZMzwfSTJPDKzA1S8mX4VLqa+fiAtveraQUBCz4FFcwVZBGbwBXIw==}
/ufo@1.4.0:
resolution: {integrity: sha512-Hhy+BhRBleFjpJ2vchUNN40qgkh0366FWJGqVLYBHev0vpHTrXSA0ryT+74UiW6KWsldNurQMKGqCm1M2zBciQ==}
/uhyphen@0.2.0:
resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==}
@@ -5178,8 +5203,8 @@ packages:
spdx-expression-parse: 3.0.1
dev: true
/vite-node@1.1.0(@types/node@20.10.3)(sass@1.69.5):
resolution: {integrity: sha512-jV48DDUxGLEBdHCQvxL1mEh7+naVy+nhUUUaPAZLd3FJgXuxQiewHcfeZebbJ6onDqNGkP4r3MhQ342PRlG81Q==}
/vite-node@1.2.2(@types/node@20.10.3)(sass@1.69.5):
resolution: {integrity: sha512-1as4rDTgVWJO3n1uHmUYqq7nsFgINQ9u+mRcXpjeOMJUmviqNKjcZB7UfRZrlM7MjYXMKpuWp5oGkjaFLnjawg==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
dependencies:
@@ -5187,7 +5212,7 @@ packages:
debug: 4.3.4
pathe: 1.1.1
picocolors: 1.0.0
vite: 5.0.12(@types/node@20.10.3)(sass@1.69.5)
vite: 5.1.3(@types/node@20.10.3)(sass@1.69.5)
transitivePeerDependencies:
- '@types/node'
- less
@@ -5198,8 +5223,8 @@ packages:
- supports-color
- terser
/vite@5.0.12(@types/node@20.10.3)(sass@1.69.5):
resolution: {integrity: sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w==}
/vite@5.1.3(@types/node@20.10.3)(sass@1.69.5):
resolution: {integrity: sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -5228,7 +5253,7 @@ packages:
dependencies:
'@types/node': 20.10.3
esbuild: 0.19.11
postcss: 8.4.32
postcss: 8.4.35
rollup: 4.6.1
sass: 1.69.5
optionalDependencies:
@@ -5249,7 +5274,7 @@ packages:
'@docsearch/css': 3.5.2
'@docsearch/js': 3.5.2
'@types/markdown-it': 13.0.7
'@vitejs/plugin-vue': 5.0.2(vite@5.0.12)(vue@3.4.3)
'@vitejs/plugin-vue': 5.0.2(vite@5.1.3)(vue@3.4.3)
'@vue/devtools-api': 6.5.1
'@vueuse/core': 10.7.1(vue@3.4.3)
'@vueuse/integrations': 10.7.1(focus-trap@7.5.4)(vue@3.4.3)
@@ -5260,7 +5285,7 @@ packages:
shikiji: 0.9.16
shikiji-core: 0.9.16
shikiji-transformers: 0.9.16
vite: 5.0.12(@types/node@20.10.3)(sass@1.69.5)
vite: 5.1.3(@types/node@20.10.3)(sass@1.69.5)
vue: 3.4.3(typescript@5.3.3)
transitivePeerDependencies:
- '@algolia/client-search'
@@ -5290,7 +5315,7 @@ packages:
- universal-cookie
dev: true
/vitest-mock-extended@1.3.1(typescript@5.3.3)(vitest@1.1.0):
/vitest-mock-extended@1.3.1(typescript@5.3.3)(vitest@1.2.2):
resolution: {integrity: sha512-OpghYjh4BDuQ/Mzs3lFMQ1QRk9D8/2O9T47MLUA5eLn7K4RWIy+MfIivYOWEyxjTENjsBnzgMihDjyNalN/K0Q==}
peerDependencies:
typescript: 3.x || 4.x || 5.x
@@ -5298,11 +5323,11 @@ packages:
dependencies:
ts-essentials: 9.3.2(typescript@5.3.3)
typescript: 5.3.3
vitest: 1.1.0(@types/node@20.10.3)(happy-dom@13.3.8)(sass@1.69.5)
vitest: 1.2.2(@types/node@20.10.3)(happy-dom@13.3.8)(sass@1.69.5)
dev: true
/vitest@1.1.0(@types/node@20.10.3)(happy-dom@13.3.8)(sass@1.69.5):
resolution: {integrity: sha512-oDFiCrw7dd3Jf06HoMtSRARivvyjHJaTxikFxuqJjO76U436PqlVw1uLn7a8OSPrhSfMGVaRakKpA2lePdw79A==}
/vitest@1.2.2(@types/node@20.10.3)(happy-dom@13.3.8)(sass@1.69.5):
resolution: {integrity: sha512-d5Ouvrnms3GD9USIK36KG8OZ5bEvKEkITFtnGv56HFaSlbItJuYr7hv2Lkn903+AvRAgSixiamozUVfORUekjw==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -5327,12 +5352,12 @@ packages:
optional: true
dependencies:
'@types/node': 20.10.3
'@vitest/expect': 1.1.0
'@vitest/runner': 1.1.0
'@vitest/snapshot': 1.1.0
'@vitest/spy': 1.1.0
'@vitest/utils': 1.1.0
acorn-walk: 8.3.0
'@vitest/expect': 1.2.2
'@vitest/runner': 1.2.2
'@vitest/snapshot': 1.2.2
'@vitest/spy': 1.2.2
'@vitest/utils': 1.2.2
acorn-walk: 8.3.2
cac: 6.7.14
chai: 4.3.10
debug: 4.3.4
@@ -5345,9 +5370,9 @@ packages:
std-env: 3.6.0
strip-literal: 1.3.0
tinybench: 2.5.1
tinypool: 0.8.1
vite: 5.0.12(@types/node@20.10.3)(sass@1.69.5)
vite-node: 1.1.0(@types/node@20.10.3)(sass@1.69.5)
tinypool: 0.8.2
vite: 5.1.3(@types/node@20.10.3)(sass@1.69.5)
vite-node: 1.2.2(@types/node@20.10.3)(sass@1.69.5)
why-is-node-running: 2.2.2
transitivePeerDependencies:
- less
@@ -5684,7 +5709,6 @@ packages:
dependencies:
buffer-crc32: 0.2.13
fd-slicer: 1.1.0
dev: false
/yocto-queue@1.0.0:
resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==}
+471 -417
View File
@@ -1,7 +1,15 @@
import { fakeBrowser } from '@webext-core/fake-browser';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, vi, expectTypeOf } from 'vitest';
import { browser } from '~/browser';
import { storage } from '~/storage';
import { WxtStorageItem, storage } from '~/storage';
/**
* This works because fakeBrowser is synchronous, 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));
}
describe('Storage Utils', () => {
beforeEach(() => {
@@ -68,10 +76,7 @@ describe('Storage Utils', () => {
const actual = await storage.getItems(params);
expect(actual).toHaveLength(3);
expected.forEach((item) => {
expect(actual).toContainEqual(item);
});
expect(actual).toEqual(expected);
});
});
@@ -384,417 +389,466 @@ describe('Storage Utils', () => {
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();
});
});
});
},
);
describe('defineItem', () => {
describe('versioning', () => {
it('should migrate values to the latest when a version upgrade is detected', async () => {
await fakeBrowser.storage.local.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 }>(`local:count`, {
defaultValue: 0,
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 }>(`local:count`, {
defaultValue: 0,
version: 3,
migrations: {
2: migrateToV2,
3: migrateToV3,
},
});
await waitForMigrations();
const actualValue = await item.getValue();
const actualMeta = await item.getMeta();
expect(actualValue).toEqual(0);
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.local.set({
count: 2,
});
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
const item = storage.defineItem<number, { v: number }>(`local:count`, {
defaultValue: 0,
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.local.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 }>(`local:count`, {
defaultValue: 0,
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.local.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 }>(`local:count`, {
defaultValue: 0,
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.local.set({
count: 0,
count$: { v: prevVersion },
});
const item = storage.defineItem(`local:count`, {
defaultValue: 0,
version: nextVersion,
});
await waitForMigrations();
await expect(item.migrate()).rejects.toThrow(
'Version downgrade detected (v2 -> v1) for "local:count"',
);
});
});
describe('getValue', () => {
it('should return the value from storage', async () => {
const expected = 2;
const item = storage.defineItem<number>(`local:count`);
await fakeBrowser.storage.local.set({ count: expected });
const actual = await item.getValue();
expect(actual).toBe(expected);
});
it('should return null if missing', async () => {
const item = storage.defineItem<number>(`local: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(`local: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 }>(`local:count`);
await fakeBrowser.storage.local.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 }>(`local: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>(`local: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>(`local: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 }>(
`local: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 }>(
`local:count`,
);
await fakeBrowser.storage.local.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(`local:count`);
await fakeBrowser.storage.local.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(`local:count`);
const expected = { v: 1 };
await fakeBrowser.storage.local.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(`local:count`);
await fakeBrowser.storage.local.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 }>(`local:count`);
await fakeBrowser.storage.local.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 }>(
`local:count`,
);
await fakeBrowser.storage.local.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(`local:key`);
const cb = vi.fn();
item.watch(cb);
await storage.setItem(`local:not-the-key`, '123');
expect(cb).not.toBeCalled();
});
it("should not trigger if the value doesn't change", async () => {
const item = storage.defineItem(`local: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(`local: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 use the default value for the newValue when the item is removed', async () => {
const defaultValue = 'default';
const item = storage.defineItem<string>(`local:key`, {
defaultValue,
});
const cb = vi.fn();
const oldValue = '123';
await item.setValue(oldValue);
item.watch(cb);
await item.removeValue();
expect(cb).toBeCalledTimes(1);
expect(cb).toBeCalledWith(defaultValue, oldValue);
});
it("should use the default value for the oldItem when the item didn't exist in storage yet", async () => {
const defaultValue = 'default';
const item = storage.defineItem<string>(`local:key`, {
defaultValue,
});
const cb = vi.fn();
const newValue = '123';
await item.removeValue();
item.watch(cb);
await item.setValue(newValue);
expect(cb).toBeCalledTimes(1);
expect(cb).toBeCalledWith(newValue, defaultValue);
});
it('should remove the listener when calling the returned function', async () => {
const item = storage.defineItem(`local: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(`local:key`);
const cb = vi.fn();
item.watch(cb);
storage.unwatch();
await item.setValue('123');
expect(cb).not.toBeCalled();
});
});
describe('defaultValue', () => {
it('should return the default value when provided', () => {
const defaultValue = 123;
const item = storage.defineItem(`local:test`, {
defaultValue,
});
expect(item.defaultValue).toBe(defaultValue);
});
it('should return null when not provided', () => {
const item = storage.defineItem<number>(`local:test`);
expect(item.defaultValue).toBeNull();
});
});
describe('types', () => {
it('should define a nullable value when options are not passed', () => {
const item = storage.defineItem<number>(`local:test`);
expectTypeOf(item).toEqualTypeOf<WxtStorageItem<number | null, {}>>();
});
it('should define a non-null value when options are passed with a nullish default value', () => {
const item = storage.defineItem(`local:test`, {
defaultValue: 123,
});
expectTypeOf(item).toEqualTypeOf<WxtStorageItem<number, {}>>();
});
it('should define a nullable value when options are passed with null default value', () => {
const item = storage.defineItem<number | null>(`local:test`, {
defaultValue: null,
});
expectTypeOf(item).toEqualTypeOf<WxtStorageItem<number | null, {}>>();
});
});
});
});
+10 -2
View File
@@ -1,6 +1,5 @@
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';
@@ -63,10 +62,18 @@ export function getArrayFromFlags<T>(
}
const aliasCommandNames = new Set<string>();
/**
* @param base Command to add this one to
* @param name The command name to add
* @param alias The CLI tool being aliased
* @param bin The CLI tool binary name. Usually the same as the alias
* @param docsUrl URL to the docs for the aliased CLI tool
*/
export function createAliasedCommand(
base: CAC,
name: string,
alias: string,
bin: string,
docsUrl: string,
) {
const aliasedCommand = base
@@ -79,7 +86,8 @@ export function createAliasedCommand(
const args = process.argv.slice(
process.argv.indexOf(aliasedCommand.name) + 1,
);
await exec(alias, args, {
const { execa } = await import('execa');
await execa(bin, args, {
stdio: 'inherit',
});
} catch {
+1
View File
@@ -140,6 +140,7 @@ createAliasedCommand(
cli,
'submit',
'publish-extension',
'wxt-publish-extension',
'https://www.npmjs.com/publish-browser-extension',
);
@@ -10,6 +10,29 @@ import { createLocationWatcher } from './location-watcher';
*
* It also provides several utilities like `ctx.setTimeout` and `ctx.setInterval` that should be used in
* content scripts instead of `window.setTimeout` or `window.setInterval`.
*
* To create context for testing, you can use the class's constructor:
*
* ```ts
* import { ContentScriptContext } from 'wxt/client';
*
* test("storage listener should be removed when context is invalidated", () => {
* const ctx = new ContentScriptContext('test');
* const item = storage.defineItem("local:count", { defaultValue: 0 });
* const watcher = vi.fn();
*
* const unwatch = item.watch(watcher);
* ctx.onInvalidated(unwatch); // Listen for invalidate here
*
* await item.setValue(1);
* expect(watcher).toBeCalledTimes(1);
* expect(watcher).toBeCalledWith(1, 0);
*
* ctx.notifyInvalidated(); // Use this function to invalidate the context
* await item.setValue(2);
* expect(watcher).toBeCalledTimes(1);
* });
* ```
*/
export class ContentScriptContext implements AbortController {
private static SCRIPT_STARTED_MESSAGE_TYPE = 'wxt:content-script-started';
@@ -367,7 +367,7 @@ describe('Content Script UIs', () => {
});
});
describe.todo('after', () => {
describe('after', () => {
it('should append the UI after the anchor', () => {
const ui = createIntegratedUi(ctx, {
position: 'inline',
+7 -12
View File
@@ -119,19 +119,18 @@ export async function createShadowRootUi<TMounted>(
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);
// Mount UI inside shadow root
mounted = options.onMount(uiContainer, shadow, shadowHost);
};
const remove = () => {
// Detatch shadow root from DOM
shadowHost.remove();
// Cleanup mounted state
options.onRemove?.(mounted);
// Detatch shadow root from DOM
shadowHost.remove();
// Remove children from uiContainer
while (uiContainer.lastChild)
uiContainer.removeChild(uiContainer.lastChild);
@@ -211,20 +210,16 @@ function mountUi(
anchor.append(root);
break;
case 'first':
if (anchor.firstChild) {
anchor.insertBefore(root, anchor.firstChild);
} else {
anchor.append(root);
}
anchor.prepend(root);
break;
case 'replace':
anchor.replaceWith(root);
break;
case 'after':
anchor.replaceWith(anchor, root);
anchor.parentElement?.insertBefore(root, anchor.nextElementSibling);
break;
case 'before':
anchor.replaceWith(root, anchor);
anchor.parentElement?.insertBefore(root, anchor);
break;
default:
options.append(anchor, root);
+13 -5
View File
@@ -10,7 +10,10 @@ import {
WxtBuilderServer,
} from '~/types';
import * as wxtPlugins from './plugins';
import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
import {
getEntrypointBundlePath,
isHtmlEntrypoint,
} from '~/core/utils/entrypoints';
export async function createViteBuilder(
inlineConfig: InlineConfig,
@@ -133,6 +136,9 @@ export async function createViteBuilder(
* 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 => {
const htmlEntrypoints = new Set(
entrypoints.filter(isHtmlEntrypoint).map((e) => e.name),
);
return {
mode: wxtConfig.mode,
plugins: [
@@ -148,10 +154,12 @@ export async function createViteBuilder(
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',
entryFileNames: ({ name }) => {
// HTML main JS files go in the chunks folder
if (htmlEntrypoints.has(name)) return 'chunks/[name]-[hash].js';
// Scripts are output in the root folder
return '[name].js';
},
// We can't control the "name", so we need a hash to prevent conflicts
assetFileNames: 'assets/[name]-[hash].[ext]',
},
@@ -1,5 +1,5 @@
import type * as vite from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
import { visualizer } from '@aklinker1/rollup-plugin-visualizer';
import { ResolvedConfig } from '~/types';
import path from 'node:path';
@@ -10,6 +10,16 @@ export function bundleAnalysis(
): vite.Plugin {
return visualizer({
template: 'raw-data',
filename: path.resolve(config.outDir, `stats-${increment++}.json`),
}) as vite.Plugin;
filename: path.resolve(
config.analysis.outputDir,
`${config.analysis.outputName}-${increment++}.json`,
),
});
}
/**
* @deprecated FOR TESTING ONLY.
*/
export function resetBundleIncrement() {
increment = 0;
}
@@ -16,8 +16,8 @@ export function devHtmlPrerender(
): vite.PluginOption {
const htmlReloadId = '@wxt/reload-html';
const resolvedHtmlReloadId = resolve(
config.root,
'node_modules/wxt/dist/virtual/reload-html.js',
config.wxtModuleDir,
'dist/virtual/reload-html.js',
);
const virtualReactRefreshId = '@wxt/virtual-react-refresh';
const resolvedVirtualReactRefreshId = '\0' + virtualReactRefreshId;
+1 -2
View File
@@ -1,6 +1,5 @@
import { createUnimport } from 'unimport';
import { ResolvedConfig } from '~/types';
import { getUnimportOptions } from '~/core/utils/unimport';
import type * as vite from 'vite';
import { extname } from 'path';
@@ -19,7 +18,7 @@ const ENABLED_EXTENSIONS = new Set([
export function unimport(
config: Omit<ResolvedConfig, 'builder'>,
): vite.PluginOption {
const options = getUnimportOptions(config);
const options = config.imports;
if (options === false) return [];
const unimport = createUnimport(options);
@@ -30,10 +30,7 @@ export function virtualEntrypoint(
const inputPath = id.replace(resolvedVirtualId, '');
const template = await fs.readFile(
resolve(
config.root,
`node_modules/wxt/dist/virtual/${type}-entrypoint.js`,
),
resolve(config.wxtModuleDir, `dist/virtual/${type}-entrypoint.js`),
'utf-8',
);
return template.replace(`virtual:user-${type}`, inputPath);
@@ -26,8 +26,8 @@ export function webextensionPolyfillMock(
alias: {
// Alias to use a mocked version of the polyfill
'webextension-polyfill': path.resolve(
config.root,
'node_modules/wxt/dist/virtual/mock-browser',
config.wxtModuleDir,
'dist/virtual/mock-browser',
),
},
},
+46 -22
View File
@@ -7,7 +7,7 @@ import {
} from '~/types';
import {
getEntrypointBundlePath,
resolvePerBrowserOption,
isHtmlEntrypoint,
} from '~/core/utils/entrypoints';
import {
getContentScriptCssFiles,
@@ -25,6 +25,8 @@ import { Mutex } from 'async-mutex';
import pc from 'picocolors';
import { relative } from 'node:path';
import { registerWxt, wxt } from './wxt';
import { unnormalizePath } from './utils/paths';
import { mapWxtOptionsToRegisteredContentScript } from './utils/content-scripts';
/**
* Creates a dev server and pre-builds all the files that need to exist before loading the extension.
@@ -51,6 +53,14 @@ export async function createServer(
// Build after starting the dev server so it can be used to transform HTML files
server.currentOutput = await internalBuild();
// Add file watchers for files not loaded by the dev server. See
// https://github.com/wxt-dev/wxt/issues/428#issuecomment-1944731870
try {
server.watcher.add(getExternalOutputDependencies(server));
} catch (err) {
wxt.config.logger.warn('Failed to register additional file paths:', err);
}
// Open browser after everything is ready to go.
await runner.openBrowser();
};
@@ -91,8 +101,8 @@ export async function createServer(
transformHtml(url, html, originalUrl) {
return builderServer.transformHtml(url, html, originalUrl);
},
reloadContentScript(contentScript) {
server.ws.send('wxt:reload-content-script', contentScript);
reloadContentScript(payload) {
server.ws.send('wxt:reload-content-script', payload);
},
reloadPage(path) {
server.ws.send('wxt:reload-page', path);
@@ -228,23 +238,12 @@ function reloadContentScripts(steps: BuildStepOutput[], server: WxtDevServer) {
const css = getContentScriptCssFiles([entry], cssMap);
server.reloadContentScript({
allFrames: resolvePerBrowserOption(
entry.options.allFrames,
wxt.config.browser,
registration: entry.options.registration,
contentScript: mapWxtOptionsToRegisteredContentScript(
entry.options,
js,
css,
),
excludeMatches: resolvePerBrowserOption(
entry.options.excludeMatches,
wxt.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, wxt.config.browser),
js,
css,
});
});
} else {
@@ -257,9 +256,7 @@ function reloadHtmlPages(
server: WxtDevServer,
): { 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'));
const htmlEntries = groups.flat().filter(isHtmlEntrypoint);
htmlEntries.forEach((entry) => {
const path = getEntrypointBundlePath(entry, wxt.config.outDir, '.html');
@@ -278,3 +275,30 @@ function getFilenameList(names: string[]): string {
})
.join(pc.dim(', '));
}
/**
* Based on the current build output, return a list of files that are:
* 1. Not in node_modules
* 2. Not inside project root
*/
function getExternalOutputDependencies(server: WxtDevServer) {
return (
server.currentOutput?.steps
.flatMap((step, i) => {
if (Array.isArray(step.entrypoints) && i === 0) {
// Dev server is already watching all HTML/esm files
return [];
}
return step.chunks.flatMap((chunk) => {
if (chunk.type === 'asset') return [];
return chunk.moduleIds;
});
})
.filter(
(file) => !file.includes('node_modules') && !file.startsWith('\x00'),
)
.map(unnormalizePath)
.filter((file) => !file.startsWith(wxt.config.root)) ?? []
);
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import path from 'node:path';
import { bun } from '../bun';
const cwd = path.resolve(__dirname, 'fixtures/bun-project');
describe.skipIf(() => process.platform === 'win32')(
'Bun Package Management Utils',
() => {
describe('listDependencies', () => {
it('should list direct dependencies', async () => {
const actual = await bun.listDependencies({ cwd });
expect(actual).toEqual([
{ name: 'flatten', version: '1.0.3' },
{ name: 'mime-types', version: '2.1.35' },
]);
});
it('should list all dependencies', async () => {
const actual = await bun.listDependencies({ cwd, all: true });
expect(actual).toEqual([
{ name: 'flatten', version: '1.0.3' },
{ name: 'mime-db', version: '1.52.0' },
{ name: 'mime-types', version: '2.1.35' },
]);
});
});
},
);
Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "bun-ls",
"dependencies": {
"mime-types": "2.1.35"
},
"devDependencies": {
"flatten": "1.0.3"
}
}
@@ -0,0 +1,42 @@
{
"name": "npm-ls",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "npm-ls",
"dependencies": {
"mime-types": "2.1.35"
},
"devDependencies": {
"flatten": "1.0.3"
}
},
"node_modules/flatten": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/flatten/-/flatten-1.0.3.tgz",
"integrity": "sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==",
"deprecated": "flatten is deprecated in favor of utility frameworks such as lodash.",
"dev": true
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
}
}
}
@@ -0,0 +1,9 @@
{
"name": "npm-ls",
"dependencies": {
"mime-types": "2.1.35"
},
"devDependencies": {
"flatten": "1.0.3"
}
}
@@ -0,0 +1,9 @@
{
"name": "pnpm-ls",
"dependencies": {
"mime-types": "2.1.35"
},
"devDependencies": {
"flatten": "1.0.3"
}
}
@@ -0,0 +1,34 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
mime-types:
specifier: 2.1.35
version: 2.1.35
devDependencies:
flatten:
specifier: 1.0.3
version: 1.0.3
packages:
/flatten@1.0.3:
resolution: {integrity: sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==}
deprecated: flatten is deprecated in favor of utility frameworks such as lodash.
dev: true
/mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
dev: false
/mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
dependencies:
mime-db: 1.52.0
dev: false
@@ -0,0 +1,10 @@
{
"name": "yarn-ls",
"packageManager": "yarn@1.22.19",
"dependencies": {
"mime-types": "2.1.35"
},
"devDependencies": {
"flatten": "1.0.3"
}
}
@@ -0,0 +1,20 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
flatten@1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.3.tgz#c1283ac9f27b368abc1e36d1ff7b04501a30356b"
integrity sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==
mime-db@1.52.0:
version "1.52.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
mime-types@2.1.35:
version "2.1.35"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
dependencies:
mime-db "1.52.0"
@@ -0,0 +1,46 @@
import { beforeAll, describe, expect, it } from 'vitest';
import path from 'node:path';
import { npm } from '../npm';
import { execaCommand } from 'execa';
import { exists } from 'fs-extra';
const cwd = path.resolve(__dirname, 'fixtures/npm-project');
describe('NPM Package Management Utils', () => {
beforeAll(async () => {
// NPM needs the modules installed for 'npm ls' to work
await execaCommand('npm i', { cwd });
});
describe('listDependencies', () => {
it('should list direct dependencies', async () => {
const actual = await npm.listDependencies({ cwd });
expect(actual).toEqual([
{ name: 'flatten', version: '1.0.3' },
{ name: 'mime-types', version: '2.1.35' },
]);
});
it('should list all dependencies', async () => {
const actual = await npm.listDependencies({ cwd, all: true });
expect(actual).toEqual([
{ name: 'flatten', version: '1.0.3' },
{ name: 'mime-types', version: '2.1.35' },
{ name: 'mime-db', version: '1.52.0' },
]);
});
});
describe('downloadDependency', () => {
it('should download the dependency as a tarball', async () => {
const downloadDir = path.resolve(cwd, 'dist');
const id = 'mime-db@1.52.0';
const expected = path.resolve(downloadDir, 'mime-db-1.52.0.tgz');
const actual = await npm.downloadDependency(id, downloadDir);
expect(actual).toEqual(expected);
expect(await exists(actual)).toBe(true);
});
});
});
@@ -0,0 +1,34 @@
import { beforeAll, describe, expect, it } from 'vitest';
import path from 'node:path';
import { pnpm } from '../pnpm';
import { execaCommand } from 'execa';
const cwd = path.resolve(__dirname, 'fixtures/pnpm-project');
process.env.WXT_PNPM_IGNORE_WORKSPACE = 'true';
describe('PNPM Package Management Utils', () => {
beforeAll(async () => {
// PNPM needs the modules installed, or 'pnpm ls' will return a blank list.
await execaCommand('pnpm i --ignore-workspace', { cwd });
});
describe('listDependencies', () => {
it('should list direct dependencies', async () => {
const actual = await pnpm.listDependencies({ cwd });
expect(actual).toEqual([
{ name: 'flatten', version: '1.0.3' },
{ name: 'mime-types', version: '2.1.35' },
]);
});
it('should list all dependencies', async () => {
const actual = await pnpm.listDependencies({ cwd, all: true });
expect(actual).toEqual([
{ name: 'flatten', version: '1.0.3' },
{ name: 'mime-types', version: '2.1.35' },
{ name: 'mime-db', version: '1.52.0' },
]);
});
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import path from 'node:path';
import { yarn } from '../yarn';
const cwd = path.resolve(__dirname, 'fixtures/yarn-project');
describe('Yarn Package Management Utils', () => {
describe('listDependencies', () => {
it('should list direct dependencies', async () => {
const actual = await yarn.listDependencies({ cwd });
expect(actual).toEqual([
{ name: 'mime-db', version: '1.52.0' },
{ name: 'flatten', version: '1.0.3' },
{ name: 'mime-types', version: '2.1.35' },
]);
});
it('should list all dependencies', async () => {
const actual = await yarn.listDependencies({ cwd, all: true });
expect(actual).toEqual([
{ name: 'mime-db', version: '1.52.0' },
{ name: 'flatten', version: '1.0.3' },
{ name: 'mime-types', version: '2.1.35' },
]);
});
});
});
+27
View File
@@ -0,0 +1,27 @@
import { dedupeDependencies, npm } from './npm';
import { WxtPackageManagerImpl } from './types';
export const bun: WxtPackageManagerImpl = {
overridesKey: 'overrides', // But also supports "resolutions"
downloadDependency(...args) {
return npm.downloadDependency(...args);
},
async listDependencies(options) {
const args = ['pm', 'ls'];
if (options?.all) {
args.push('--all');
}
const { execa } = await import('execa');
const res = await execa('bun', args, { cwd: options?.cwd });
return dedupeDependencies(
res.stdout
.split('\n')
.slice(1) // Skip the first line, is not a dependency
.map((line) => line.trim())
.map((line) => /.* (@?\S+)@(\S+)$/.exec(line))
// @ts-expect-error: Filtering to known non-null matches
.filter<RegExpExecArray>((match) => !!match)
.map(([_, name, version]) => ({ name, version })),
);
},
};
+76
View File
@@ -0,0 +1,76 @@
import {
detectPackageManager,
addDependency,
addDevDependency,
ensureDependencyInstalled,
installDependencies,
removeDependency,
PackageManager,
PackageManagerName,
} from 'nypm';
import { WxtPackageManager } from '~/types';
import { bun } from './bun';
import { WxtPackageManagerImpl } from './types';
import { yarn } from './yarn';
import { pnpm } from './pnpm';
import { npm } from './npm';
export async function createWxtPackageManager(
root: string,
): Promise<WxtPackageManager> {
const pm = await detectPackageManager(root, {
includeParentDirs: true,
});
// Use requirePm to prevent throwing errors before the package manager utils are used.
const requirePm = <T>(cb: (pm: PackageManager) => T) => {
if (pm == null) throw Error('Could not detect package manager');
return cb(pm);
};
return {
get name() {
return requirePm((pm) => pm.name);
},
get command() {
return requirePm((pm) => pm.command);
},
get version() {
return requirePm((pm) => pm.version);
},
get majorVersion() {
return requirePm((pm) => pm.majorVersion);
},
get lockFile() {
return requirePm((pm) => pm.lockFile);
},
get files() {
return requirePm((pm) => pm.files);
},
addDependency,
addDevDependency,
ensureDependencyInstalled,
installDependencies,
removeDependency,
get overridesKey() {
return requirePm((pm) => packageManagers[pm.name].overridesKey);
},
downloadDependency(...args) {
return requirePm((pm) =>
packageManagers[pm.name].downloadDependency(...args),
);
},
listDependencies(...args) {
return requirePm((pm) =>
packageManagers[pm.name].listDependencies(...args),
);
},
};
}
const packageManagers: Record<PackageManagerName, WxtPackageManagerImpl> = {
npm,
pnpm,
bun,
yarn,
};
+85
View File
@@ -0,0 +1,85 @@
import { Dependency } from '~/types';
import { WxtPackageManagerImpl } from './types';
import path from 'node:path';
import { ensureDir } from 'fs-extra';
export const npm: WxtPackageManagerImpl = {
overridesKey: 'overrides',
async downloadDependency(id, downloadDir) {
await ensureDir(downloadDir);
const { execa } = await import('execa');
const res = await execa('npm', ['pack', id, '--json'], {
cwd: downloadDir,
});
const packed: PackedDependency[] = JSON.parse(res.stdout);
return path.resolve(downloadDir, packed[0].filename);
},
async listDependencies(options) {
const args = ['ls', '--json'];
if (options?.all) {
args.push('--depth', 'Infinity');
}
const { execa } = await import('execa');
const res = await execa('npm', args, { cwd: options?.cwd });
const project: NpmListProject = JSON.parse(res.stdout);
return flattenNpmListOutput([project]);
},
};
export function flattenNpmListOutput(projects: NpmListProject[]): Dependency[] {
const queue: Record<string, NpmListDependency>[] = projects.flatMap(
(project) => {
const acc: Record<string, NpmListDependency>[] = [];
if (project.dependencies) acc.push(project.dependencies);
if (project.devDependencies) acc.push(project.devDependencies);
return acc;
},
);
const dependencies: Dependency[] = [];
while (queue.length > 0) {
Object.entries(queue.pop()!).forEach(([name, meta]) => {
dependencies.push({
name,
version: meta.version,
});
if (meta.dependencies) queue.push(meta.dependencies);
if (meta.devDependencies) queue.push(meta.devDependencies);
});
}
return dedupeDependencies(dependencies);
}
export function dedupeDependencies(dependencies: Dependency[]): Dependency[] {
const hashes = new Set<string>();
return dependencies.filter((dep) => {
const hash = `${dep.name}@${dep.version}`;
if (hashes.has(hash)) {
return false;
} else {
hashes.add(hash);
return true;
}
});
}
export interface NpmListProject {
name: string;
dependencies?: Record<string, NpmListDependency>;
devDependencies?: Record<string, NpmListDependency>;
}
export interface NpmListDependency {
version: string;
resolved?: string;
overridden?: boolean;
dependencies?: Record<string, NpmListDependency>;
devDependencies?: Record<string, NpmListDependency>;
}
interface PackedDependency {
id: string;
name: string;
version: string;
filename: string;
}
+28
View File
@@ -0,0 +1,28 @@
import { NpmListProject, flattenNpmListOutput, npm } from './npm';
import { WxtPackageManagerImpl } from './types';
export const pnpm: WxtPackageManagerImpl = {
overridesKey: 'resolutions', // "pnpm.overrides" has a higher priority, but I don't want to deal with nesting
downloadDependency(...args) {
return npm.downloadDependency(...args);
},
async listDependencies(options) {
const args = ['ls', '--json'];
if (options?.all) {
args.push('--depth', 'Infinity');
}
// Helper for testing - since WXT uses pnpm workspaces, folders inside it don't behave like
// standalone projects unless you pass the --ignore-workspace flag.
if (
typeof process !== 'undefined' &&
process.env.WXT_PNPM_IGNORE_WORKSPACE === 'true'
) {
args.push('--ignore-workspace');
}
const { execa } = await import('execa');
const res = await execa('pnpm', args, { cwd: options?.cwd });
const projects: NpmListProject[] = JSON.parse(res.stdout);
return flattenNpmListOutput(projects);
},
};
+6
View File
@@ -0,0 +1,6 @@
import { WxtPackageManager } from '~/types';
export type WxtPackageManagerImpl = Pick<
WxtPackageManager,
'downloadDependency' | 'listDependencies' | 'overridesKey'
>;
+54
View File
@@ -0,0 +1,54 @@
import { Dependency } from '~/types';
import { WxtPackageManagerImpl } from './types';
import { dedupeDependencies, npm } from './npm';
export const yarn: WxtPackageManagerImpl = {
overridesKey: 'resolutions',
downloadDependency(...args) {
return npm.downloadDependency(...args);
},
async listDependencies(options) {
const args = ['list', '--json'];
if (options?.all) {
args.push('--depth', 'Infinity');
}
const { execa } = await import('execa');
const res = await execa('yarn', args, { cwd: options?.cwd });
const tree = res.stdout
.split('\n')
.map<JsonLine>((line) => JSON.parse(line))
.find((line) => line.type === 'tree')?.data as JsonLineTree | undefined;
if (tree == null) throw Error("'yarn list --json' did not output a tree");
const queue = [...tree.trees];
const dependencies: Dependency[] = [];
while (queue.length > 0) {
const { name: treeName, children } = queue.pop()!;
const match = /(@?\S+)@(\S+)$/.exec(treeName);
if (match) {
const [_, name, version] = match;
dependencies.push({ name, version });
}
if (children != null) {
queue.push(...children);
}
}
return dedupeDependencies(dependencies);
},
};
type JsonLine =
| { type: unknown; data: unknown }
| { type: 'tree'; data: JsonLineTree };
interface JsonLineTree {
type: 'list';
trees: Tree[];
}
interface Tree {
name: string;
children?: Tree[];
}
+132
View File
@@ -4,10 +4,12 @@ import {
fakeArray,
fakeBackgroundEntrypoint,
fakeBuildOutput,
fakeContentScriptEntrypoint,
fakeEntrypoint,
fakeManifestCommand,
fakeOptionsEntrypoint,
fakePopupEntrypoint,
fakeSidepanelEntrypoint,
setFakeWxt,
} from '../testing/fake-objects';
import { Manifest } from 'webextension-polyfill';
@@ -756,6 +758,136 @@ describe('Manifest Utils', () => {
]);
});
});
describe('registration', () => {
it('should throw an error when registration=runtime for MV2', async () => {
const cs: ContentScriptEntrypoint = fakeContentScriptEntrypoint({
options: {
registration: 'runtime',
},
});
const entrypoints = [cs];
const buildOutput: Omit<BuildOutput, 'manifest'> = {
publicAssets: [],
steps: [{ entrypoints: cs, chunks: [] }],
};
setFakeWxt({
config: {
manifestVersion: 2,
},
});
await expect(
generateManifest(entrypoints, buildOutput),
).rejects.toThrowError();
});
it('should add host_permissions instead of content_scripts when registration=runtime', async () => {
const cs: ContentScriptEntrypoint = {
type: 'content-script',
name: 'one',
inputPath: 'entrypoints/one.content.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://google.com/*'],
registration: 'runtime',
},
skipped: false,
};
const styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/one.css',
};
const entrypoints = [cs];
const buildOutput: Omit<BuildOutput, 'manifest'> = {
publicAssets: [],
steps: [{ entrypoints: cs, chunks: [styles] }],
};
setFakeWxt({
config: {
manifestVersion: 3,
outDir,
command: 'build',
},
});
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
);
expect(actual.content_scripts).toEqual([]);
expect(actual.host_permissions).toEqual(['*://google.com/*']);
});
});
});
describe('sidepanel', () => {
it.each(['chrome', 'safari', 'edge'])(
'should include a side_panel ignoring all options for %s',
async (browser) => {
const sidepanel = fakeSidepanelEntrypoint({
outputDir: outDir,
});
const buildOutput = fakeBuildOutput();
setFakeWxt({
config: {
manifestVersion: 3,
browser,
outDir,
},
});
const expected = {
side_panel: {
default_path: 'sidepanel.html',
},
};
const { manifest: actual } = await generateManifest(
[sidepanel],
buildOutput,
);
expect(actual).toMatchObject(expected);
},
);
it.each(['firefox'])(
'should include a sidebar_action for %s',
async (browser) => {
const sidepanel = fakeSidepanelEntrypoint({
outputDir: outDir,
});
const buildOutput = fakeBuildOutput();
setFakeWxt({
config: {
manifestVersion: 3,
browser,
outDir,
},
});
const expected = {
sidebar_action: {
default_panel: 'sidepanel.html',
open_at_install: sidepanel.options.openAtInstall,
default_title: sidepanel.options.defaultTitle,
default_icon: sidepanel.options.defaultIcon,
browser_style: sidepanel.options.browserStyle,
},
};
const { manifest: actual } = await generateManifest(
[sidepanel],
buildOutput,
);
expect(actual).toMatchObject(expected);
},
);
});
describe('web_accessible_resources', () => {
@@ -1,10 +1,13 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
BackgroundEntrypoint,
BackgroundEntrypointOptions,
BaseEntrypointOptions,
ContentScriptEntrypoint,
GenericEntrypoint,
OptionsEntrypoint,
PopupEntrypoint,
SidepanelEntrypoint,
} from '~/types';
import { resolve } from 'path';
import { findEntrypoints } from '../find-entrypoints';
@@ -242,7 +245,7 @@ describe('findEntrypoints', () => {
])(
'should find and load background entrypoint config from %s',
async (path, expected) => {
const options: BackgroundEntrypoint['options'] = {
const options: BackgroundEntrypointOptions = {
type: 'module',
};
globMock.mockResolvedValueOnce([path]);
@@ -256,13 +259,87 @@ describe('findEntrypoints', () => {
},
);
it.each<[string, string, SidepanelEntrypoint]>([
[
'sidepanel.html',
`
<html>
<head>
<title>Default Title</title>
<meta name="manifest.default_icon" content="{ '16': '/icon/16.png' }" />
<meta name="manifest.open_at_install" content="true" />
</head>
</html>
`,
{
type: 'sidepanel',
name: 'sidepanel',
inputPath: resolve(config.entrypointsDir, 'sidepanel.html'),
outputDir: config.outDir,
options: {
defaultTitle: 'Default Title',
defaultIcon: { '16': '/icon/16.png' },
openAtInstall: true,
},
skipped: false,
},
],
[
'sidepanel/index.html',
`<html></html>`,
{
type: 'sidepanel',
name: 'sidepanel',
inputPath: resolve(config.entrypointsDir, 'sidepanel/index.html'),
options: {},
outputDir: config.outDir,
skipped: false,
},
],
[
'named.sidepanel.html',
`<html></html>`,
{
type: 'sidepanel',
name: 'named',
inputPath: resolve(config.entrypointsDir, 'named.sidepanel.html'),
options: {},
outputDir: config.outDir,
skipped: false,
},
],
[
'named.sidepanel/index.html',
`<html></html>`,
{
type: 'sidepanel',
name: 'named',
inputPath: resolve(config.entrypointsDir, 'named.sidepanel/index.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
])(
'should find and load sidepanel entrypoint config from %s',
async (path, content, expected) => {
globMock.mockResolvedValueOnce([path]);
readFileMock.mockResolvedValueOnce(content);
const entrypoints = await findEntrypoints();
expect(entrypoints).toHaveLength(1);
expect(entrypoints[0]).toEqual(expected);
},
);
it('should remove type=module from MV2 background scripts', async () => {
setFakeWxt({
config: {
manifestVersion: 2,
},
});
const options: BackgroundEntrypoint['options'] = {
const options: BackgroundEntrypointOptions = {
type: 'module',
};
globMock.mockResolvedValueOnce(['background.ts']);
@@ -279,7 +356,7 @@ describe('findEntrypoints', () => {
manifestVersion: 3,
},
});
const options: BackgroundEntrypoint['options'] = {
const options: BackgroundEntrypointOptions = {
type: 'module',
};
globMock.mockResolvedValueOnce(['background.ts']);
@@ -331,7 +408,7 @@ describe('findEntrypoints', () => {
outputDir: config.outDir,
skipped: false,
};
const options: GenericEntrypoint['options'] = {};
const options: BaseEntrypointOptions = {};
globMock.mockResolvedValueOnce([path]);
importEntrypointFileMock.mockResolvedValue(options);
@@ -462,52 +539,6 @@ describe('findEntrypoints', () => {
},
],
// sidepanel
[
'sidepanel.html',
{
type: 'sidepanel',
name: 'sidepanel',
inputPath: resolve(config.entrypointsDir, 'sidepanel.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
[
'sidepanel/index.html',
{
type: 'sidepanel',
name: 'sidepanel',
inputPath: resolve(config.entrypointsDir, 'sidepanel/index.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
[
'named.sidepanel.html',
{
type: 'sidepanel',
name: 'named',
inputPath: resolve(config.entrypointsDir, 'named.sidepanel.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
[
'named.sidepanel/index.html',
{
type: 'sidepanel',
name: 'named',
inputPath: resolve(config.entrypointsDir, 'named.sidepanel/index.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
// devtools
[
'devtools.html',
+140 -144
View File
@@ -10,6 +10,10 @@ import {
OptionsEntrypoint,
PopupEntrypoint,
UnlistedScriptDefinition,
PopupEntrypointOptions,
OptionsEntrypointOptions,
SidepanelEntrypoint,
SidepanelEntrypointOptions,
} from '~/types';
import fs from 'fs-extra';
import { minimatch } from 'minimatch';
@@ -19,7 +23,7 @@ import { importEntrypointFile } from '~/core/utils/building';
import glob from 'fast-glob';
import {
getEntrypointName,
resolvePerBrowserOption,
resolvePerBrowserOptions,
} from '~/core/utils/entrypoints';
import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '~/core/utils/constants';
import { CSS_EXTENSIONS_PATTERN } from '~/core/utils/paths';
@@ -71,6 +75,8 @@ export async function findEntrypoints(): Promise<Entrypoint[]> {
switch (type) {
case 'popup':
return await getPopupEntrypoint(info);
case 'sidepanel':
return await getSidepanelEntrypoint(info);
case 'options':
return await getOptionsEntrypoint(info);
case 'background':
@@ -199,153 +205,81 @@ function preventNoEntrypoints(files: EntrypointInfo[]) {
}
}
function getHtmlBaseOptions(document: Document): BaseEntrypointOptions {
const options: BaseEntrypointOptions = {};
const includeContent = document
.querySelector("meta[name='manifest.include']")
?.getAttribute('content');
if (includeContent) {
options.include = JSON5.parse(includeContent);
}
const excludeContent = document
.querySelector("meta[name='manifest.exclude']")
?.getAttribute('content');
if (excludeContent) {
options.exclude = JSON5.parse(excludeContent);
}
return options;
}
/**
* @param path Absolute path to the popup HTML file.
* @param content String contents of the file at the path.
*/
async function getPopupEntrypoint({
inputPath,
name,
skipped,
}: EntrypointInfo): Promise<PopupEntrypoint> {
const content = await fs.readFile(inputPath, 'utf-8');
const { document } = parseHTML(content);
const options: PopupEntrypoint['options'] = getHtmlBaseOptions(document);
const title = document.querySelector('title');
if (title != null) options.defaultTitle = title.textContent ?? undefined;
const defaultIconContent = document
.querySelector("meta[name='manifest.default_icon']")
?.getAttribute('content');
if (defaultIconContent) {
try {
options.defaultIcon = JSON5.parse(defaultIconContent);
} catch (err) {
wxt.logger.fatal(
`Failed to parse default_icon meta tag content as JSON5. content=${defaultIconContent}`,
err,
);
}
}
const mv2TypeContent = document
.querySelector("meta[name='manifest.type']")
?.getAttribute('content');
if (mv2TypeContent) {
options.mv2Key =
mv2TypeContent === 'page_action' ? 'page_action' : 'browser_action';
}
const browserStyleContent = document
.querySelector("meta[name='manifest.browser_style']")
?.getAttribute('content');
if (browserStyleContent) {
options.browserStyle = browserStyleContent === 'true';
}
async function getPopupEntrypoint(
info: EntrypointInfo,
): Promise<PopupEntrypoint> {
const options = await getHtmlEntrypointOptions<PopupEntrypointOptions>(
info,
{
browserStyle: 'browse_style',
exclude: 'exclude',
include: 'include',
defaultIcon: 'default_icon',
defaultTitle: 'default_title',
mv2Key: 'type',
},
{
defaultTitle: (document) =>
document.querySelector('title')?.textContent || undefined,
},
{
defaultTitle: (content) => content,
mv2Key: (content) =>
content === 'page_action' ? 'page_action' : 'browser_action',
},
);
return {
type: 'popup',
name: 'popup',
options,
inputPath,
options: resolvePerBrowserOptions(options, wxt.config.browser),
inputPath: info.inputPath,
outputDir: wxt.config.outDir,
skipped,
skipped: info.skipped,
};
}
/**
* @param path Absolute path to the options HTML file.
* @param content String contents of the file at the path.
*/
async function getOptionsEntrypoint({
inputPath,
name,
skipped,
}: EntrypointInfo): Promise<OptionsEntrypoint> {
const content = await fs.readFile(inputPath, 'utf-8');
const { document } = parseHTML(content);
const options: OptionsEntrypoint['options'] = getHtmlBaseOptions(document);
const openInTabContent = document
.querySelector("meta[name='manifest.open_in_tab']")
?.getAttribute('content');
if (openInTabContent) {
options.openInTab = openInTabContent === 'true';
}
const chromeStyleContent = document
.querySelector("meta[name='manifest.chrome_style']")
?.getAttribute('content');
if (chromeStyleContent) {
options.chromeStyle = chromeStyleContent === 'true';
}
const browserStyleContent = document
.querySelector("meta[name='manifest.browser_style']")
?.getAttribute('content');
if (browserStyleContent) {
options.browserStyle = browserStyleContent === 'true';
}
async function getOptionsEntrypoint(
info: EntrypointInfo,
): Promise<OptionsEntrypoint> {
const options = await getHtmlEntrypointOptions<OptionsEntrypointOptions>(
info,
{
browserStyle: 'browse_style',
chromeStyle: 'chrome_style',
exclude: 'exclude',
include: 'include',
openInTab: 'open_in_tab',
},
);
return {
type: 'options',
name: 'options',
options,
inputPath,
options: resolvePerBrowserOptions(options, wxt.config.browser),
inputPath: info.inputPath,
outputDir: wxt.config.outDir,
skipped,
skipped: info.skipped,
};
}
/**
* @param path Absolute path to the HTML file.
* @param content String contents of the file at the path.
*/
async function getUnlistedPageEntrypoint({
inputPath,
name,
skipped,
}: EntrypointInfo): Promise<GenericEntrypoint> {
const content = await fs.readFile(inputPath, 'utf-8');
const { document } = parseHTML(content);
async function getUnlistedPageEntrypoint(
info: EntrypointInfo,
): Promise<GenericEntrypoint> {
const options = await getHtmlEntrypointOptions<BaseEntrypointOptions>(info, {
exclude: 'exclude',
include: 'include',
});
return {
type: 'unlisted-page',
name: getEntrypointName(wxt.config.entrypointsDir, inputPath),
inputPath,
name: info.name,
inputPath: info.inputPath,
outputDir: wxt.config.outDir,
options: getHtmlBaseOptions(document),
skipped,
options,
skipped: info.skipped,
};
}
/**
* @param path Absolute path to the script's file.
* @param content String contents of the file at the path.
*/
async function getUnlistedScriptEntrypoint({
inputPath,
name,
@@ -358,21 +292,17 @@ async function getUnlistedScriptEntrypoint({
`${name}: Default export not found, did you forget to call "export default defineUnlistedScript(...)"?`,
);
}
const { main: _, ...moduleOptions } = defaultExport;
const options: Omit<UnlistedScriptDefinition, 'main'> = moduleOptions;
const { main: _, ...options } = defaultExport;
return {
type: 'unlisted-script',
name,
inputPath,
outputDir: wxt.config.outDir,
options,
options: resolvePerBrowserOptions(options, wxt.config.browser),
skipped,
};
}
/**
* @param path Absolute path to the background's TS file.
*/
async function getBackgroundEntrypoint({
inputPath,
name,
@@ -400,21 +330,11 @@ async function getBackgroundEntrypoint({
name,
inputPath,
outputDir: wxt.config.outDir,
options: {
...options,
type: resolvePerBrowserOption(options.type, wxt.config.browser),
persistent: resolvePerBrowserOption(
options.persistent,
wxt.config.browser,
),
},
options: resolvePerBrowserOptions(options, wxt.config.browser),
skipped,
};
}
/**
* @param path Absolute path to the content script's TS file.
*/
async function getContentScriptEntrypoint({
inputPath,
name,
@@ -432,11 +352,87 @@ async function getContentScriptEntrypoint({
name,
inputPath,
outputDir: resolve(wxt.config.outDir, CONTENT_SCRIPT_OUT_DIR),
options,
options: resolvePerBrowserOptions(options, wxt.config.browser),
skipped,
};
}
async function getSidepanelEntrypoint(
info: EntrypointInfo,
): Promise<SidepanelEntrypoint> {
const options = await getHtmlEntrypointOptions<SidepanelEntrypointOptions>(
info,
{
browserStyle: 'browse_style',
exclude: 'exclude',
include: 'include',
defaultIcon: 'default_icon',
defaultTitle: 'default_title',
openAtInstall: 'open_at_install',
},
{
defaultTitle: (document) =>
document.querySelector('title')?.textContent || undefined,
},
{
defaultTitle: (content) => content,
},
);
return {
type: 'sidepanel',
name: info.name,
options: resolvePerBrowserOptions(options, wxt.config.browser),
inputPath: info.inputPath,
outputDir: wxt.config.outDir,
skipped: info.skipped,
};
}
/**
* Parse the HTML tags to extract options from them.
*/
async function getHtmlEntrypointOptions<T extends BaseEntrypointOptions>(
info: EntrypointInfo,
keyMap: Record<keyof T, string>,
queries?: Partial<{
[key in keyof T]: (
document: Document,
manifestKey: string,
) => string | undefined;
}>,
parsers?: Partial<{ [key in keyof T]: (content: string) => T[key] }>,
): Promise<T> {
const content = await fs.readFile(info.inputPath, 'utf-8');
const { document } = parseHTML(content);
const options = {} as T;
const defaultQuery = (manifestKey: string) =>
document
.querySelector(`meta[name='manifest.${manifestKey}']`)
?.getAttribute('content');
Object.entries(keyMap).forEach(([_key, manifestKey]) => {
const key = _key as keyof T;
const content = queries?.[key]
? queries[key]!(document, manifestKey)
: defaultQuery(manifestKey);
if (content) {
try {
options[key] = (parsers?.[key] ?? JSON5.parse)(content);
} catch (err) {
wxt.logger.fatal(
`Failed to parse meta tag content. Usually this means you have invalid JSON5 content (content=${content})`,
err,
);
}
}
});
return options;
}
const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type']> = {
'sandbox.html': 'sandbox',
'sandbox/index.html': 'sandbox',
+35 -12
View File
@@ -1,9 +1,15 @@
import { UnimportOptions, createUnimport } from 'unimport';
import { Entrypoint } from '~/types';
import { Unimport, createUnimport } from 'unimport';
import {
EslintGlobalsPropValue,
Entrypoint,
WxtResolvedUnimportOptions,
} from '~/types';
import fs from 'fs-extra';
import { relative, resolve } from 'path';
import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
import { getUnimportOptions } from '~/core/utils/unimport';
import {
getEntrypointBundlePath,
isHtmlEntrypoint,
} from '~/core/utils/entrypoints';
import { getEntrypointGlobals, getGlobals } from '~/core/utils/globals';
import { normalizePath } from '~/core/utils/paths';
import path from 'node:path';
@@ -21,9 +27,12 @@ export async function generateTypesDir(
const references: string[] = [];
const imports = getUnimportOptions(wxt.config);
if (imports !== false) {
references.push(await writeImportsDeclarationFile(imports));
if (wxt.config.imports !== false) {
const unimport = createUnimport(wxt.config.imports);
references.push(await writeImportsDeclarationFile(unimport));
if (wxt.config.imports.eslintrc.enabled) {
await writeImportsEslintFile(unimport, wxt.config.imports);
}
}
references.push(await writePathsDeclarationFile(entrypoints));
@@ -34,11 +43,8 @@ export async function generateTypesDir(
await writeTsConfigFile(mainReference);
}
async function writeImportsDeclarationFile(
unimportOptions: Partial<UnimportOptions>,
): Promise<string> {
async function writeImportsDeclarationFile(unimport: Unimport) {
const filePath = resolve(wxt.config.typesDir, 'imports.d.ts');
const unimport = createUnimport(unimportOptions);
// Load project imports into unimport memory so they are output via generateTypeDeclarations
await unimport.scanImportsFromDir(undefined, { cwd: wxt.config.srcDir });
@@ -53,6 +59,23 @@ async function writeImportsDeclarationFile(
return filePath;
}
async function writeImportsEslintFile(
unimport: Unimport,
options: WxtResolvedUnimportOptions,
) {
const globals: Record<string, EslintGlobalsPropValue> = {};
const eslintrc = { globals };
(await unimport.getImports())
.map((i) => i.as ?? i.name)
.filter(Boolean)
.sort()
.forEach((name) => {
eslintrc.globals[name] = options.eslintrc.globalsPropValue;
});
await fs.writeJson(options.eslintrc.filePath, eslintrc, { spaces: 2 });
}
async function writePathsDeclarationFile(
entrypoints: Entrypoint[],
): Promise<string> {
@@ -62,7 +85,7 @@ async function writePathsDeclarationFile(
getEntrypointBundlePath(
entry,
wxt.config.outDir,
entry.inputPath.endsWith('.html') ? '.html' : '.js',
isHtmlEntrypoint(entry) ? '.html' : '.js',
),
)
.concat(await getPublicFiles())
+11 -5
View File
@@ -2,7 +2,6 @@ import createJITI, { TransformOptions as JitiTransformOptions } from 'jiti';
import { createUnimport } from 'unimport';
import fs from 'fs-extra';
import { relative, resolve } from 'node:path';
import { getUnimportOptions } from '~/core/utils/unimport';
import { removeProjectImportStatements } from '~/core/utils/strings';
import { normalizePath } from '~/core/utils/paths';
import { TransformOptions, transformSync } from 'esbuild';
@@ -30,7 +29,7 @@ export async function importEntrypointFile<T>(path: string): Promise<T> {
const normalPath = normalizePath(path);
const unimport = createUnimport({
...getUnimportOptions(wxt.config),
...wxt.config.imports,
// Only allow specific imports, not all from the project
dirs: [],
});
@@ -53,8 +52,8 @@ export async function importEntrypointFile<T>(path: string): Promise<T> {
esmResolve: true,
alias: {
'webextension-polyfill': resolve(
wxt.config.root,
'node_modules/wxt/dist/virtual/mock-browser.js',
wxt.config.wxtModuleDir,
'dist/virtual/mock-browser.js',
),
},
// Continue using node to load TS files even if `bun run --bun` is detected. Jiti does not
@@ -106,6 +105,13 @@ function getEsbuildOptions(opts: JitiTransformOptions): TransformOptions {
return {
format: 'cjs',
loader: isJsx ? 'tsx' : 'ts',
jsx: isJsx ? 'automatic' : undefined,
...(isJsx
? {
// `h` and `Fragment` are undefined, but that's OK because JSX is never evaluated while
// grabbing the entrypoint's options.
jsxFactory: 'h',
jsxFragment: 'Fragment',
}
: undefined),
};
}
+12 -15
View File
@@ -16,8 +16,8 @@ import {
validateEntrypoints,
} from '../validation';
import consola from 'consola';
import { exec } from '../exec';
import { wxt } from '../../wxt';
import { mergeJsonOutputs } from '@aklinker1/rollup-plugin-visualizer';
/**
* Builds the extension based on an internal config. No more config discovery is performed, the
@@ -87,24 +87,21 @@ export async function internalBuild(): Promise<BuildOutput> {
}
async function combineAnalysisStats(): Promise<void> {
const unixFiles = await glob(`stats-*.json`, {
cwd: wxt.config.outDir,
const unixFiles = await glob(`${wxt.config.analysis.outputName}-*.json`, {
cwd: wxt.config.analysis.outputDir,
absolute: true,
});
const absolutePaths = unixFiles.map(unnormalizePath);
await exec(
'rollup-plugin-visualizer',
[
...absolutePaths,
'--template',
wxt.config.analysis.template,
'--filename',
wxt.config.analysis.outputFile,
],
{ cwd: wxt.config.root, stdio: 'inherit' },
);
await Promise.all(absolutePaths.map((statsFile) => fs.remove(statsFile)));
await mergeJsonOutputs({
inputs: absolutePaths,
template: wxt.config.analysis.template,
filename: wxt.config.analysis.outputFile,
});
if (!wxt.config.analysis.keepArtifacts) {
await Promise.all(absolutePaths.map((statsFile) => fs.remove(statsFile)));
}
}
function printValidationResults({
+78 -5
View File
@@ -8,6 +8,8 @@ import {
UserManifest,
ExtensionRunnerConfig,
WxtDevServer,
WxtResolvedUnimportOptions,
Logger,
} from '~/types';
import path from 'node:path';
import { createFsCache } from '~/core/utils/cache';
@@ -15,6 +17,7 @@ import consola, { LogLevels } from 'consola';
import { createViteBuilder } from '~/core/builders/vite';
import defu from 'defu';
import { NullablyRequired } from '../types';
import { isModuleInstalled } from '../package';
/**
* Given an inline config, discover the config file if necessary, merge the results, resolve any
@@ -34,6 +37,7 @@ export async function resolveConfig(
let userConfigMetadata: ResolvedConfig['userConfigMetadata'] | undefined;
if (inlineConfig.configFile !== false) {
const { config: loadedConfig, ...metadata } = await loadConfig<UserConfig>({
configFile: inlineConfig.configFile,
name: 'wxt',
cwd: inlineConfig.root ?? process.cwd(),
rcFile: false,
@@ -67,6 +71,7 @@ export async function resolveConfig(
inlineConfig.root ?? userConfig.root ?? process.cwd(),
);
const wxtDir = path.resolve(root, '.wxt');
const wxtModuleDir = await resolveWxtModuleDir();
const srcDir = path.resolve(root, mergedConfig.srcDir ?? root);
const entrypointsDir = path.resolve(
srcDir,
@@ -100,6 +105,13 @@ export async function resolveConfig(
}).map(([key, value]) => [key, path.resolve(root, value)]),
);
const analysisOutputFile = path.resolve(
root,
mergedConfig.analysis?.outputFile ?? 'stats.html',
);
const analysisOutputDir = path.dirname(analysisOutputFile);
const analysisOutputName = path.parse(analysisOutputFile).name;
const finalConfig: Omit<ResolvedConfig, 'builder'> = {
browser,
command,
@@ -108,7 +120,7 @@ export async function resolveConfig(
filterEntrypoints,
env,
fsCache: createFsCache(wxtDir),
imports: mergedConfig.imports ?? {},
imports: await getUnimportOptions(wxtDir, logger, mergedConfig),
logger,
manifest: await resolveManifestConfig(env, mergedConfig.manifest),
manifestVersion,
@@ -116,6 +128,7 @@ export async function resolveConfig(
outBaseDir,
outDir,
publicDir,
wxtModuleDir,
root,
runnerConfig,
srcDir,
@@ -129,10 +142,10 @@ export async function resolveConfig(
analysis: {
enabled: mergedConfig.analysis?.enabled ?? false,
template: mergedConfig.analysis?.template ?? 'treemap',
outputFile: path.resolve(
root,
mergedConfig.analysis?.outputFile ?? 'stats.html',
),
outputFile: analysisOutputFile,
outputDir: analysisOutputDir,
outputName: analysisOutputName,
keepArtifacts: mergedConfig.analysis?.keepArtifacts ?? false,
},
userConfigMetadata: userConfigMetadata ?? {},
alias,
@@ -245,6 +258,7 @@ function resolveInternalZipConfig(
root: string,
mergedConfig: InlineConfig,
): NullablyRequired<ResolvedConfig['zip']> {
const downloadedPackagesDir = path.resolve(root, '.wxt/local_modules');
return {
name: undefined,
sourcesTemplate: '{{name}}-{{version}}-sources.zip',
@@ -264,5 +278,64 @@ function resolveInternalZipConfig(
// From user
...(mergedConfig.zip?.excludeSources ?? []),
],
downloadPackages: mergedConfig.zip?.downloadPackages ?? [],
downloadedPackagesDir,
};
}
async function getUnimportOptions(
wxtDir: string,
logger: Logger,
config: InlineConfig,
): Promise<WxtResolvedUnimportOptions | false> {
if (config.imports === false) return false;
const enabledConfig = config.imports?.eslintrc?.enabled;
let enabled: boolean;
switch (enabledConfig) {
case undefined:
case 'auto':
enabled = await isModuleInstalled('eslint');
break;
default:
enabled = enabledConfig;
}
const defaultOptions: WxtResolvedUnimportOptions = {
debugLog: logger.debug,
imports: [
{ name: 'defineConfig', from: 'wxt' },
{ name: 'fakeBrowser', from: 'wxt/testing' },
],
presets: [
{ package: 'wxt/client' },
{ package: 'wxt/browser' },
{ package: 'wxt/sandbox' },
{ package: 'wxt/storage' },
],
warn: logger.warn,
dirs: ['components', 'composables', 'hooks', 'utils'],
eslintrc: {
enabled,
filePath: path.resolve(wxtDir, 'eslintrc-auto-import.json'),
globalsPropValue: true,
},
};
return defu<WxtResolvedUnimportOptions, [WxtResolvedUnimportOptions]>(
config.imports ?? {},
defaultOptions,
);
}
/**
* Returns the path to `node_modules/wxt`.
*/
async function resolveWxtModuleDir() {
const requireResolve =
require?.resolve ??
(await import('node:module')).default.createRequire(import.meta.url)
.resolve;
// require.resolve returns the wxt/dist/index file, not the package's root directory, which we want to return
return path.resolve(requireResolve('wxt'), '../..');
}
+36 -28
View File
@@ -1,7 +1,5 @@
import type { Manifest } from '~/browser';
import type { Manifest, Scripting } from '~/browser';
import { ContentScriptEntrypoint } from '~/types';
import { resolvePerBrowserOption } from './entrypoints';
import { wxt } from '../wxt';
/**
* Returns a unique and consistent string hash based on a content scripts options.
@@ -11,7 +9,11 @@ import { wxt } from '../wxt';
export function hashContentScriptOptions(
options: ContentScriptEntrypoint['options'],
): string {
const simplifiedOptions = mapWxtOptionsToContentScript(options);
const simplifiedOptions = mapWxtOptionsToContentScript(
options,
undefined,
undefined,
);
// Remove undefined fields and use defaults to generate hash
Object.keys(simplifiedOptions).forEach((key) => {
@@ -45,33 +47,39 @@ export function hashContentScriptOptions(
export function mapWxtOptionsToContentScript(
options: ContentScriptEntrypoint['options'],
): Omit<Manifest.ContentScript, 'js' | 'css'> {
js: string[] | undefined,
css: string[] | undefined,
): Manifest.ContentScript {
return {
matches: resolvePerBrowserOption(options.matches, wxt.config.browser),
all_frames: resolvePerBrowserOption(options.allFrames, wxt.config.browser),
match_about_blank: resolvePerBrowserOption(
options.matchAboutBlank,
wxt.config.browser,
),
exclude_globs: resolvePerBrowserOption(
options.excludeGlobs,
wxt.config.browser,
),
exclude_matches: resolvePerBrowserOption(
options.excludeMatches,
wxt.config.browser,
),
include_globs: resolvePerBrowserOption(
options.includeGlobs,
wxt.config.browser,
),
run_at: resolvePerBrowserOption(options.runAt, wxt.config.browser),
matches: options.matches,
all_frames: options.allFrames,
match_about_blank: options.matchAboutBlank,
exclude_globs: options.excludeGlobs,
exclude_matches: options.excludeMatches,
include_globs: options.includeGlobs,
run_at: options.runAt,
css,
js,
// @ts-expect-error: untyped chrome options
match_origin_as_fallback: resolvePerBrowserOption(
options.matchOriginAsFallback,
wxt.config.browser,
),
match_origin_as_fallback: options.matchOriginAsFallback,
world: options.world,
};
}
export function mapWxtOptionsToRegisteredContentScript(
options: ContentScriptEntrypoint['options'],
js: string[] | undefined,
css: string[] | undefined,
): Omit<Scripting.RegisteredContentScript, 'id'> {
return {
allFrames: options.allFrames,
excludeMatches: options.excludeMatches,
matches: options.matches,
runAt: options.runAt,
js,
css,
// @ts-expect-error: Chrome accepts this, not typed in webextension-polyfill (https://developer.chrome.com/docs/extensions/reference/scripting/#type-RegisteredContentScript)
world: options.world,
};
}
+34 -1
View File
@@ -1,4 +1,9 @@
import { Entrypoint, PerBrowserOption, TargetBrowser } from '~/types';
import {
Entrypoint,
PerBrowserOption,
ResolvedPerBrowserOptions,
TargetBrowser,
} from '~/types';
import path, { relative, resolve } from 'node:path';
import { normalizePath } from './paths';
@@ -46,3 +51,31 @@ export function resolvePerBrowserOption<T>(
return (option as any)[browser];
return option;
}
/**
* Given an entrypoint option, resolve it's value based on a target browser.
*
* defaultIcon is special, it's the only key that's a record, which can confuse this function. So
* it's been manually excluded from resolution.
*/
export function resolvePerBrowserOptions<
T extends Record<string, any>,
TKeys extends keyof T,
>(options: T, browser: TargetBrowser): ResolvedPerBrowserOptions<T, TKeys> {
// @ts-expect-error: Object.entries is untyped.
return Object.fromEntries(
Object.entries(options).map(([key, value]) => [
key,
key === 'defaultIcon' ? value : resolvePerBrowserOption(value, browser),
]),
);
}
/**
* Returns true when the entrypoint is an HTML entrypoint.
*
* Naively just checking the file extension of the input path.
*/
export function isHtmlEntrypoint(entrypoint: Entrypoint): boolean {
return entrypoint.inputPath.endsWith('.html');
}
-26
View File
@@ -1,26 +0,0 @@
import type { Options } from 'execa';
import managePath from 'manage-path';
import { resolve } from 'node:path';
import { wxt } from '../wxt';
const managedPath = managePath(process.env);
/**
* Wrapper around `execa` with a modified `PATH` variable containing CLI tools from WXT's dependencies.
*/
export const exec = async (
file: string,
args?: readonly string[],
options?: Options,
) => {
// Reset so the same path isn't added multiple times
managedPath.restore();
// Add subdependency path for PNPM shamefully-hoist=false
managedPath.push(
resolve(wxt.config.root, 'node_modules/wxt/node_modules/.bin'),
);
const { execa } = await import('execa');
return await execa(file, args, options);
};
+51 -36
View File
@@ -6,13 +6,11 @@ import {
ContentScriptEntrypoint,
OptionsEntrypoint,
PopupEntrypoint,
SidepanelEntrypoint,
} from '~/types';
import fs from 'fs-extra';
import { resolve } from 'path';
import {
getEntrypointBundlePath,
resolvePerBrowserOption,
} from './entrypoints';
import { getEntrypointBundlePath } from './entrypoints';
import { ContentSecurityPolicy } from './content-security-policy';
import {
hashContentScriptOptions,
@@ -185,7 +183,9 @@ function addEntrypoints(
| undefined;
const popup = entriesByType['popup']?.[0] as PopupEntrypoint | undefined;
const sandboxes = entriesByType['sandbox'];
const sidepanels = entriesByType['sidepanel'];
const sidepanels = entriesByType['sidepanel'] as
| SidepanelEntrypoint[]
| undefined;
if (background) {
const script = getEntrypointBundlePath(
@@ -331,9 +331,11 @@ function addEntrypoints(
if (wxt.config.browser === 'firefox') {
manifest.sidebar_action = {
// TODO: Add options to side panel
// ...defaultSidepanel.options,
default_panel: page,
browser_style: defaultSidepanel.options.browserStyle,
default_icon: defaultSidepanel.options.defaultIcon,
default_title: defaultSidepanel.options.defaultTitle,
open_at_install: defaultSidepanel.options.openAtInstall,
};
} else if (wxt.config.manifestVersion === 3) {
// @ts-expect-error: Untyped
@@ -353,40 +355,54 @@ function addEntrypoints(
// Don't add content scripts to the manifest in dev mode for MV3 - they're managed and reloaded
// at runtime
if (wxt.config.command === 'serve' && wxt.config.manifestVersion === 3) {
const hostPermissions = new Set<string>(manifest.host_permissions ?? []);
contentScripts.forEach((script) => {
const matches = resolvePerBrowserOption(
script.options.matches,
wxt.config.browser,
);
matches.forEach((matchPattern) => {
hostPermissions.add(matchPattern);
script.options.matches.forEach((matchPattern) => {
addHostPermission(manifest, matchPattern);
});
});
hostPermissions.forEach((permission) =>
addHostPermission(manifest, permission),
);
} else {
const hashToEntrypointsMap = contentScripts.reduce((map, script) => {
const hash = hashContentScriptOptions(script.options);
if (map.has(hash)) map.get(hash)?.push(script);
else map.set(hash, [script]);
return map;
}, new Map<string, ContentScriptEntrypoint[]>());
const newContentScripts = Array.from(hashToEntrypointsMap.entries()).map(
([, scripts]) => ({
...mapWxtOptionsToContentScript(scripts[0].options),
css: getContentScriptCssFiles(scripts, cssMap),
js: scripts.map((entry) =>
// Manifest scripts
const hashToEntrypointsMap = contentScripts
.filter((cs) => cs.options.registration !== 'runtime')
.reduce((map, script) => {
const hash = hashContentScriptOptions(script.options);
if (map.has(hash)) map.get(hash)?.push(script);
else map.set(hash, [script]);
return map;
}, new Map<string, ContentScriptEntrypoint[]>());
const manifestContentScripts = Array.from(
hashToEntrypointsMap.values(),
).map((scripts) =>
mapWxtOptionsToContentScript(
scripts[0].options,
scripts.map((entry) =>
getEntrypointBundlePath(entry, wxt.config.outDir, '.js'),
),
}),
getContentScriptCssFiles(scripts, cssMap),
),
);
if (newContentScripts.length >= 0) {
if (manifestContentScripts.length >= 0) {
manifest.content_scripts ??= [];
manifest.content_scripts.push(...newContentScripts);
manifest.content_scripts.push(...manifestContentScripts);
}
// Runtime content scripts
const runtimeContentScripts = contentScripts.filter(
(cs) => cs.options.registration === 'runtime',
);
if (
runtimeContentScripts.length > 0 &&
wxt.config.manifestVersion === 2
) {
throw Error(
'Cannot use `registration: "runtime"` with MV2 content scripts, it is a MV3-only feature.',
);
}
runtimeContentScripts.forEach((script) => {
script.options.matches.forEach((matchPattern) => {
addHostPermission(manifest, matchPattern);
});
});
}
const contentScriptCssResources = getContentScriptCssWebAccessibleResources(
@@ -519,10 +535,9 @@ export function getContentScriptCssWebAccessibleResources(
resources.push({
resources: [cssFile],
matches: resolvePerBrowserOption(
script.options.matches,
wxt.config.browser,
).map((matchPattern) => stripPathFromMatchPattern(matchPattern)),
matches: script.options.matches.map((matchPattern) =>
stripPathFromMatchPattern(matchPattern),
),
});
});
+4
View File
@@ -20,3 +20,7 @@ export async function getPackageJson(): Promise<
return {};
}
}
export function isModuleInstalled(name: string) {
return import(name).then(() => true).catch(() => false);
}
+46 -2
View File
@@ -21,6 +21,7 @@ import {
BuildStepOutput,
UserManifest,
Wxt,
SidepanelEntrypoint,
} from '~/types';
import { mock } from 'vitest-mock-extended';
import { vi } from 'vitest';
@@ -148,6 +149,30 @@ export const fakePopupEntrypoint = fakeObjectCreator<PopupEntrypoint>(() => ({
skipped: faker.datatype.boolean(),
}));
export const fakeSidepanelEntrypoint = fakeObjectCreator<SidepanelEntrypoint>(
() => ({
type: 'sidepanel',
inputPath: 'entrypoints/sidepanel.html',
name: 'sidepanel',
outputDir: fakeDir('.output'),
options: {
defaultTitle: faker.helpers.arrayElement([
faker.person.fullName(),
undefined,
]),
defaultIcon: faker.helpers.arrayElement([
{
'16': 'icon/16.png',
'24': 'icon/24.png',
'64': 'icon/64.png',
},
]),
openAtInstall: faker.helpers.arrayElement([true, false, undefined]),
},
skipped: faker.datatype.boolean(),
}),
);
export const fakeGenericEntrypoint = fakeObjectCreator<GenericEntrypoint>(
() => ({
type: faker.helpers.arrayElement([
@@ -155,7 +180,6 @@ export const fakeGenericEntrypoint = fakeObjectCreator<GenericEntrypoint>(
'bookmarks',
'history',
'newtab',
'sidepanel',
'devtools',
'unlisted-page',
'unlisted-script',
@@ -216,7 +240,20 @@ export const fakeResolvedConfig = fakeObjectCreator<ResolvedConfig>(() => {
entrypointsDir: fakeDir(),
env: { browser, command, manifestVersion, mode },
fsCache: mock<FsCache>(),
imports: {},
imports: {
eslintrc: {
enabled: faker.datatype.boolean(),
filePath: fakeFile(),
globalsPropValue: faker.helpers.arrayElement([
true,
false,
'readable',
'readonly',
'writable',
'writeable',
] as const),
},
},
logger: mock(),
manifest: fakeUserManifest(),
manifestVersion,
@@ -225,6 +262,7 @@ export const fakeResolvedConfig = fakeObjectCreator<ResolvedConfig>(() => {
outDir: fakeDir(),
publicDir: fakeDir(),
root: fakeDir(),
wxtModuleDir: fakeDir(),
runnerConfig: {
config: {},
},
@@ -237,6 +275,9 @@ export const fakeResolvedConfig = fakeObjectCreator<ResolvedConfig>(() => {
enabled: false,
template: 'treemap',
outputFile: fakeFile(),
outputDir: fakeDir(),
outputName: 'stats',
keepArtifacts: false,
},
zip: {
artifactTemplate: '{{name}}-{{version}}.zip',
@@ -245,6 +286,8 @@ export const fakeResolvedConfig = fakeObjectCreator<ResolvedConfig>(() => {
sourcesRoot: fakeDir(),
sourcesTemplate: '{{name}}-sources.zip',
name: faker.person.firstName().toLowerCase(),
downloadedPackagesDir: fakeDir(),
downloadPackages: [],
},
transformManifest: () => {},
userConfigMetadata: {},
@@ -265,6 +308,7 @@ export const fakeWxt = fakeObjectCreator<Wxt>(() => ({
hooks: mock(),
logger: mock(),
reloadConfig: vi.fn(),
pm: mock(),
}));
export function setFakeWxt(overrides?: DeepPartial<Wxt>) {
-27
View File
@@ -1,27 +0,0 @@
import { UnimportOptions } from 'unimport';
import { ResolvedConfig } from '~/types';
import { defu } from 'defu';
export function getUnimportOptions(
config: Omit<ResolvedConfig, 'builder'>,
): Partial<UnimportOptions | false> {
if (config.imports === false) return false;
const defaultOptions: Partial<UnimportOptions> = {
debugLog: config.logger.debug,
imports: [
{ name: 'defineConfig', from: 'wxt' },
{ name: 'fakeBrowser', from: 'wxt/testing' },
],
presets: [
{ package: 'wxt/client' },
{ package: 'wxt/browser' },
{ package: 'wxt/sandbox' },
{ package: 'wxt/storage' },
],
warn: config.logger.warn,
dirs: ['components', 'composables', 'hooks', 'utils'],
};
return defu(config.imports, defaultOptions);
}
+3
View File
@@ -1,6 +1,7 @@
import { InlineConfig, Wxt, WxtDevServer, WxtHooks } from '~/types';
import { resolveConfig } from './utils/building';
import { createHooks } from 'hookable';
import { createWxtPackageManager } from './package-managers';
/**
* Global variable set once `createWxt` is called once. Since this variable is used everywhere, this
@@ -18,6 +19,7 @@ export async function registerWxt(
): Promise<void> {
const config = await resolveConfig(inlineConfig, command, server);
const hooks = createHooks<WxtHooks>();
const pm = await createWxtPackageManager(config.root);
wxt = {
config,
@@ -28,6 +30,7 @@ export async function registerWxt(
async reloadConfig() {
wxt.config = await resolveConfig(inlineConfig, command, server);
},
pm,
};
// Initialize hooks
+124 -22
View File
@@ -1,6 +1,5 @@
import { InlineConfig } from '~/types';
import zipdir from 'zip-dir';
import { dirname, relative, resolve } from 'node:path';
import path from 'node:path';
import fs from 'fs-extra';
import { kebabCaseAlphanumeric } from '~/core/utils/strings';
import { getPackageJson } from '~/core/utils/package';
@@ -9,10 +8,13 @@ import { formatDuration } from '~/core/utils/time';
import { printFileList } from '~/core/utils/log/printFileList';
import { internalBuild } from '~/core/utils/building';
import { registerWxt, wxt } from './wxt';
import JSZip from 'jszip';
import glob from 'fast-glob';
import { normalizePath } from './utils/paths';
/**
* Build and zip the extension for distribution.
* @param config Opitonal config that will override your `<root>/wxt.config.ts`.
* @param config Optional config that will override your `<root>/wxt.config.ts`.
* @returns A list of all files included in the ZIP.
*/
export async function zip(config?: InlineConfig): Promise<string[]> {
@@ -26,7 +28,7 @@ export async function zip(config?: InlineConfig): Promise<string[]> {
const projectName =
wxt.config.zip.name ??
kebabCaseAlphanumeric(
(await getPackageJson())?.name || dirname(process.cwd()),
(await getPackageJson())?.name || path.dirname(process.cwd()),
);
const applyTemplate = (template: string): string =>
template
@@ -43,31 +45,29 @@ export async function zip(config?: InlineConfig): Promise<string[]> {
// ZIP output directory
const outZipFilename = applyTemplate(wxt.config.zip.artifactTemplate);
const outZipPath = resolve(wxt.config.outBaseDir, outZipFilename);
await zipdir(wxt.config.outDir, {
saveTo: outZipPath,
});
const outZipPath = path.resolve(wxt.config.outBaseDir, outZipFilename);
await zipDir(wxt.config.outDir, outZipPath);
zipFiles.push(outZipPath);
// ZIP sources for Firefox
if (wxt.config.browser === 'firefox') {
const { overrides, files: downloadedPackages } =
await downloadPrivatePackages();
const sourcesZipFilename = applyTemplate(wxt.config.zip.sourcesTemplate);
const sourcesZipPath = resolve(wxt.config.outBaseDir, sourcesZipFilename);
await zipdir(wxt.config.zip.sourcesRoot, {
saveTo: sourcesZipPath,
filter(path) {
const relativePath = relative(wxt.config.zip.sourcesRoot, path);
return (
wxt.config.zip.includeSources.some((pattern) =>
minimatch(relativePath, pattern),
) ||
!wxt.config.zip.excludeSources.some((pattern) =>
minimatch(relativePath, pattern),
)
);
const sourcesZipPath = path.resolve(
wxt.config.outBaseDir,
sourcesZipFilename,
);
await zipDir(wxt.config.zip.sourcesRoot, sourcesZipPath, {
include: wxt.config.zip.includeSources,
exclude: wxt.config.zip.excludeSources,
transform(file, content) {
if (file.endsWith('package.json')) {
return addOverridesToPackageJson(content, overrides);
}
},
additionalFiles: downloadedPackages,
});
zipFiles.push(sourcesZipPath);
}
@@ -81,3 +81,105 @@ export async function zip(config?: InlineConfig): Promise<string[]> {
return zipFiles;
}
async function zipDir(
directory: string,
outputPath: string,
options?: {
include?: string[];
exclude?: string[];
transform?: (
file: string,
content: string,
) => Promise<string | undefined | void> | string | undefined | void;
additionalWork?: (archive: JSZip) => Promise<void> | void;
additionalFiles?: string[];
},
): Promise<void> {
const archive = new JSZip();
const files = (
await glob('**/*', {
cwd: directory,
// Ignore node_modules, otherwise this glob step takes forever
ignore: ['**/node_modules'],
onlyFiles: true,
})
).filter((relativePath) => {
return (
wxt.config.zip.includeSources.some((pattern) =>
minimatch(relativePath, pattern),
) ||
!wxt.config.zip.excludeSources.some((pattern) =>
minimatch(relativePath, pattern),
)
);
});
const filesToZip = [
...files,
...(options?.additionalFiles ?? []).map((file) =>
path.relative(directory, file),
),
];
for (const file of filesToZip) {
const absolutePath = path.resolve(directory, file);
if (file.endsWith('.json')) {
const content = await fs.readFile(absolutePath, 'utf-8');
archive.file(
file,
(await options?.transform?.(file, content)) || content,
);
} else {
const content = await fs.readFile(absolutePath);
archive.file(file, content);
}
}
await options?.additionalWork?.(archive);
const buffer = await archive.generateAsync({ type: 'base64' });
await fs.writeFile(outputPath, buffer, 'base64');
}
async function downloadPrivatePackages() {
const overrides: Record<string, string> = {};
const files: string[] = [];
if (wxt.config.zip.downloadPackages.length > 0) {
const _downloadPackages = new Set(wxt.config.zip.downloadPackages);
const allPackages = await wxt.pm.listDependencies({
all: true,
cwd: wxt.config.root,
});
const downloadPackages = allPackages.filter((pkg) =>
_downloadPackages.has(pkg.name),
);
for (const pkg of downloadPackages) {
wxt.logger.info(`Downloading package: ${pkg.name}@${pkg.version}`);
const id = `${pkg.name}@${pkg.version}`;
const tgzPath = await wxt.pm.downloadDependency(
id,
wxt.config.zip.downloadedPackagesDir,
);
files.push(tgzPath);
overrides[id] =
'file://./' + normalizePath(path.relative(wxt.config.root, tgzPath));
}
}
return { overrides, files };
}
function addOverridesToPackageJson(
content: string,
overrides: Record<string, string>,
): string {
if (Object.keys(overrides).length === 0) return content;
const oldPackage = JSON.parse(content);
const newPackage = {
...oldPackage,
[wxt.pm.overridesKey]: {
...oldPackage[wxt.pm.overridesKey],
...overrides,
},
};
return JSON.stringify(newPackage, null, 2);
}
+89 -35
View File
@@ -1,5 +1,5 @@
/**
* Simplfied storage APIs with support for versioned fields, snapshots, metadata, and item definitions.
* Simplified storage APIs with support for versioned fields, snapshots, metadata, and item definitions.
*
* See [the guide](https://wxt.dev/guide/storage.html) for more information.
*
@@ -7,6 +7,7 @@
*/
import { Storage, browser } from '~/browser';
import { dequal } from 'dequal/lite';
import { logger } from './sandbox/utils/logger';
export const storage = createStorage();
@@ -31,7 +32,7 @@ function createStorage(): WxtStorage {
const driverKey = key.substring(deliminatorIndex + 1);
if (driverKey == null)
throw Error(
`Storage key should be in the form of "area:key", but recieved "${key}"`,
`Storage key should be in the form of "area:key", but received "${key}"`,
);
return {
@@ -247,7 +248,7 @@ function createStorage(): WxtStorage {
driver.unwatch();
});
},
defineItem: (key, opts) => {
defineItem: (key: string, opts?: WxtStorageItemOptions<any>) => {
const { driver, driverKey } = resolveKey(key);
const { version: targetVersion = 1, migrations = {} } = opts ?? {};
@@ -256,21 +257,24 @@ function createStorage(): WxtStorage {
'Storage item version cannot be less than 1. Initial versions should be set to 1, not 0.',
);
}
const runMigrations = async () => {
const [value, meta] = await Promise.all([
// TODO: Optimize with getItems
getItem(driver, driverKey, undefined),
getMeta(driver, driverKey),
const migrate = async () => {
const driverMetaKey = getMetaKey(driverKey);
const [{ value }, { value: meta }] = await driver.getItems([
driverKey,
driverMetaKey,
]);
if (value == null) return;
const currentVersion = meta.v ?? 1;
const currentVersion = meta?.v ?? 1;
if (currentVersion > targetVersion) {
throw Error(
`[wxt/storage] Migration ignored for "${key}", version downgrade detected (${currentVersion} -> ${targetVersion})`,
`Version downgrade detected (v${currentVersion} -> v${targetVersion}) for "${key}"`,
);
}
logger.debug(
`Running storage migration for ${key}: v${currentVersion} -> v${targetVersion}`,
);
const migrationsToRun = Array.from(
{ length: targetVersion - currentVersion },
(_, i) => currentVersion + i + 1,
@@ -281,23 +285,57 @@ function createStorage(): WxtStorage {
(await migrations?.[migrateToVersion]?.(migratedValue)) ??
migratedValue;
}
await Promise.all([
// TODO: Optimize with `setItem`
setItem(driver, driverKey, migratedValue),
setMeta(driver, driverKey, { v: targetVersion }),
await driver.setItems([
{ key: driverKey, value: migratedValue },
{ key: driverMetaKey, value: { ...meta, v: targetVersion } },
]);
logger.debug(
`Storage migration completed for ${key} v${targetVersion}`,
{ migratedValue },
);
};
let _migrationsCompleted = runMigrations();
const migrationsDone =
opts?.migrations == null
? Promise.resolve()
: migrate().catch((err) => {
logger.error(`Migration failed for ${key}`, err);
});
const getDefaultValue = () => opts?.defaultValue ?? null;
return {
_migrationsCompleted,
getValue: () => getItem(driver, driverKey, opts),
getMeta: () => getMeta(driver, driverKey),
setValue: (value) => setItem(driver, driverKey, value),
setMeta: (properties) => setMeta(driver, driverKey, properties),
removeValue: (opts) => removeItem(driver, driverKey, opts),
removeMeta: (properties) => removeMeta(driver, driverKey, properties),
watch: (cb) => watch(driver, driverKey, cb),
get defaultValue() {
return getDefaultValue();
},
getValue: async () => {
await migrationsDone;
return await getItem(driver, driverKey, opts);
},
getMeta: async () => {
await migrationsDone;
return await getMeta(driver, driverKey);
},
setValue: async (value) => {
await migrationsDone;
return await setItem(driver, driverKey, value);
},
setMeta: async (properties) => {
await migrationsDone;
return await setMeta(driver, driverKey, properties);
},
removeValue: async (opts) => {
await migrationsDone;
return await removeItem(driver, driverKey, opts);
},
removeMeta: async (properties) => {
await migrationsDone;
return await removeMeta(driver, driverKey, properties);
},
watch: (cb) =>
watch(driver, driverKey, (newValue, oldValue) =>
cb(newValue ?? getDefaultValue(), oldValue ?? getDefaultValue()),
),
migrate,
};
},
};
@@ -323,7 +361,10 @@ function createDriver(
);
}
return browser.storage[storageArea];
const area = browser.storage[storageArea];
if (area == null)
throw Error(`"browser.storage.${storageArea}" is undefined`);
return area;
};
const watchListeners = new Set<
(changes: Storage.StorageAreaOnChangedChangesType) => void
@@ -398,7 +439,8 @@ export interface WxtStorage {
*/
getItem<T>(key: string, opts?: GetItemOptions<T>): Promise<T | null>;
/**
* Get multiple items from storage. There is no guarentee of order in the returned array.
* Get multiple items from storage. The return order is guaranteed to be the same as the order
* requested.
*
* @example
* await storage.getItems(["local:installDate", "session:someCounter"]);
@@ -476,26 +518,29 @@ export interface WxtStorage {
): Promise<Record<string, unknown>>;
/**
* Restores the results of `snapshot`. If new properties have been saved since the snapshot, they are
* not overridden. Only values existing in the snapshot are overritten.
* not overridden. Only values existing in the snapshot are overridden.
*/
restoreSnapshot(base: string, data: any): Promise<void>;
/**
* Watch for changes to a specific key in storage.
*/
watch<T>(key: string, cb: WatchCallback<T>): Unwatch;
watch<T>(key: string, cb: WatchCallback<T | null>): Unwatch;
/**
* Remove all watch listeners.
*/
unwatch(): void;
/**
* Define a constant with utilities for reading/writing to a single value in storage.
* Define a storage item with a default value, type, or versioning.
*
* @example
* export const installDate = storage.defineItem<number>("local:installDate");
* Read full docs: https://wxt.dev/guide/storage.html#defining-storage-items
*/
defineItem<TValue, TMetadata extends Record<string, unknown> = {}>(
key: string,
options?: WxtStorageItemOptions<TValue>,
): WxtStorageItem<TValue | null, TMetadata>;
defineItem<TValue, TMetadata extends Record<string, unknown> = {}>(
key: string,
options: WxtStorageItemOptions<TValue>,
): WxtStorageItem<TValue, TMetadata>;
}
@@ -508,7 +553,7 @@ interface WxtStorageDriver {
removeItems(keys: string[]): Promise<void>;
snapshot(): Promise<Record<string, unknown>>;
restoreSnapshot(data: Record<string, unknown>): Promise<void>;
watch<T>(key: string, cb: WatchCallback<T>): Unwatch;
watch<T>(key: string, cb: WatchCallback<T | null>): Unwatch;
unwatch(): void;
}
@@ -516,6 +561,7 @@ export interface WxtStorageItem<
TValue,
TMetadata extends Record<string, unknown>,
> {
defaultValue: TValue;
/**
* Get the latest value from storage.
*/
@@ -527,7 +573,7 @@ export interface WxtStorageItem<
/**
* Set the value in storage.
*/
setValue(value: TValue | null): Promise<void>;
setValue(value: TValue): Promise<void>;
/**
* Set metadata properties.
*/
@@ -544,6 +590,13 @@ export interface WxtStorageItem<
* Listen for changes to the value in storage.
*/
watch(cb: WatchCallback<TValue>): Unwatch;
/**
* If there are migrations defined on the storage item, migrate to the latest version.
*
* **This function is ran automatically whenever the extension updates**, so you don't have to call it
* manually.
*/
migrate(): Promise<void>;
}
export interface GetItemOptions<T> {
@@ -570,7 +623,8 @@ export interface SnapshotOptions {
excludeKeys?: string[];
}
export interface WxtStorageItemOptions<T> extends GetItemOptions<T> {
export interface WxtStorageItemOptions<T> {
defaultValue: T;
/**
* Provide a version number for the storage item to enable migrations. When changing the version
* in the future, migration functions will be ran on application startup.
@@ -592,7 +646,7 @@ export type NullablePartial<T> = {
/**
* Callback called when a value in storage is changed.
*/
export type WatchCallback<T> = (newValue: T | null, oldValue: T | null) => void;
export type WatchCallback<T> = (newValue: T, oldValue: T) => void;
/**
* Call to remove a watch listener
*/
+370 -149
View File
@@ -3,10 +3,11 @@ import type { Manifest, Scripting } from '~/browser';
import { UnimportOptions } from 'unimport';
import { LogLevel } from 'consola';
import { ContentScriptContext } from '../client/content-scripts/content-script-context';
import type { PluginVisualizerOptions } from 'rollup-plugin-visualizer';
import type { PluginVisualizerOptions } from '@aklinker1/rollup-plugin-visualizer';
import type { FSWatcher } from 'chokidar';
import { ResolvedConfig as C12ResolvedConfig } from 'c12';
import { Hookable, NestedHooks } from 'hookable';
import type * as Nypm from 'nypm';
export interface InlineConfig {
/**
@@ -77,7 +78,7 @@ export interface InlineConfig {
* })
* ```
*/
imports?: Partial<UnimportOptions> | false;
imports?: WxtUnimportOptions | false;
/**
* Explicitly set a browser to build for. This will override the default browser for each command,
* and can be overridden by the command line `--browser` option.
@@ -171,6 +172,31 @@ export interface InlineConfig {
* ]
*/
excludeSources?: string[];
/**
* The Firefox review process requires the extension be buildable from source to make reviewing
* easier. This field allows you to use private packages without exposing your auth tokens.
*
* Just list the name of all the packages you want to download and include in the sources zip.
* Usually, these will be private packages behind auth tokens, but they don't have to be.
*
* All packages listed here will be downloaded to in `.wxt/local_modules/` and an `overrides` or
* `resolutions` field (depending on your package manager) will be added to the `package.json`,
* pointing to the downloaded packages.
*
* > ***DO NOT include versions or version filters.*** Just the package name. If multiple
* > versions of a package are present in the project, all versions will be downloaded and
* > referenced in the package.json correctly.
*
* @default []
*
* @example
* // Correct:
* ["@scope/package-name", "package-name"]
*
* // Incorrect, don't include versions!!!
* ["@scope/package-name@1.1.3", "package-name@^2"]
*/
downloadPackages?: string[];
};
/**
@@ -212,9 +238,24 @@ export interface InlineConfig {
/**
* Name of the output HTML file. Relative to the project's root directory.
*
* Changing the filename of the outputFile also effects the names of the artifacts generated
* when setting `keepArtifacts` to true:
* - "stats.html" => "stats-*.json"
* - "stats/bundle.html" => "bundle-*.json"
* - ".analysis/index.html" => "index-*.json"
*
* @default "stats.html"
*/
outputFile?: string;
/**
* By default, the `stats-*.json` artifacts generated during bundle analysis are deleted. Set to
* `true` to keep them.
*
* One stats file is output per build step.
*
* @default false
*/
keepArtifacts?: boolean;
};
/**
* Add additional paths to the `.wxt/tsconfig.json`. Use this instead of overwriting the `paths`
@@ -381,17 +422,20 @@ export interface WxtDevServer
/**
* Tell the extension to restart a content script.
*
* @param contentScript The manifest definition for a content script
* @param payload Information about the content script to reload.
*/
reloadContentScript: (
contentScript: Omit<Scripting.RegisteredContentScript, 'id'>,
) => void;
reloadContentScript: (payload: ReloadContentScriptPayload) => void;
/**
* Grab the latest runner config and restart the browser.
*/
restartBrowser: () => void;
}
export interface ReloadContentScriptPayload {
registration?: BaseContentScriptEntrypointOptions['registration'];
contentScript: Omit<Scripting.RegisteredContentScript, 'id'>;
}
export type TargetBrowser = string;
export type TargetManifestVersion = 2 | 3;
@@ -409,131 +453,37 @@ export interface Logger {
}
export interface BaseEntrypointOptions {
/**
* List of target browsers to include this entrypoint in. Defaults to being included in all
* builds. Cannot be used with `exclude`. You must choose one of the two options.
*
* @default undefined
*/
include?: TargetBrowser[];
/**
* List of target browsers to exclude this entrypoint from. Cannot be used with `include`. You
* must choose one of the two options.
*
* @default undefined
*/
exclude?: TargetBrowser[];
}
export interface BaseEntrypoint {
export interface BackgroundEntrypointOptions extends BaseEntrypointOptions {
persistent?: PerBrowserOption<boolean>;
/**
* The entrypoint's name. This is the filename or dirname without the type suffix.
* Set to `"module"` to output the background entrypoint as ESM. ESM outputs can share chunks and
* reduce the overall size of the bundled extension.
*
* Examples:
* - `popup.html` &rarr; `popup`
* - `options/index.html` &rarr; `options`
* - `named.sandbox.html` &rarr; `named`
* - `named.sandbox/index.html` &rarr; `named`
* - `sandbox.html` &rarr; `sandbox`
* - `sandbox/index.html` &rarr; `sandbox`
* - `overlay.content.ts` &rarr; `overlay`
* - `overlay.content/index.ts` &rarr; `overlay`
* When `undefined`, the background is bundled individually into an IIFE format.
*
* The name is used when generating an output file:
* `<entrypoint.outputDir>/<entrypoint.name>.<ext>`
* @default undefined
*/
name: string;
/**
* Absolute path to the entrypoint's input file.
*/
inputPath: string;
/**
* Absolute path to the entrypoint's output directory. Can be the`InternalConfg.outDir` or a
* subdirectory of it.
*/
outputDir: string;
options: BaseEntrypointOptions;
skipped: boolean;
type?: PerBrowserOption<'module'>;
}
export interface GenericEntrypoint extends BaseEntrypoint {
type:
| 'sandbox'
| 'bookmarks'
| 'history'
| 'newtab'
| 'sidepanel'
| 'devtools'
| 'unlisted-page'
| 'unlisted-script'
| 'unlisted-style'
| 'content-script-style';
}
export interface BackgroundEntrypoint extends BaseEntrypoint {
type: 'background';
options: {
persistent?: boolean;
type?: 'module';
} & BaseEntrypointOptions;
}
export interface ContentScriptEntrypoint extends BaseEntrypoint {
type: 'content-script';
options: Omit<ContentScriptDefinition, 'main'> & BaseEntrypointOptions;
}
export interface PopupEntrypoint extends BaseEntrypoint {
type: 'popup';
options: {
/**
* Defaults to "browser_action" to be equivalent to MV3's "action" key
*/
mv2Key?: 'browser_action' | 'page_action';
defaultIcon?: Record<string, string>;
defaultTitle?: string;
browserStyle?: boolean;
} & BaseEntrypointOptions;
}
export interface OptionsEntrypoint extends BaseEntrypoint {
type: 'options';
options: {
openInTab?: boolean;
browserStyle?: boolean;
chromeStyle?: boolean;
} & BaseEntrypointOptions;
}
export type Entrypoint =
| GenericEntrypoint
| BackgroundEntrypoint
| ContentScriptEntrypoint
| PopupEntrypoint
| OptionsEntrypoint;
export type EntrypointGroup = Entrypoint | Entrypoint[];
export type OnContentScriptStopped = (cb: () => void) => void;
export type ContentScriptDefinition =
| ContentScriptIsolatedWorldDefinition
| ContentScriptMainWorldDefinition;
export interface ContentScriptIsolatedWorldDefinition
extends ContentScriptBaseDefinition {
/**
* See https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts#isolated_world
* @default "ISOLATED"
*/
world?: 'ISOLATED';
/**
* Main function executed when the content script is loaded.
*/
main(ctx: ContentScriptContext): void | Promise<void>;
}
export interface ContentScriptMainWorldDefinition
extends ContentScriptBaseDefinition {
/**
* See https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts#isolated_world
*/
world: 'MAIN';
/**
* Main function executed when the content script is loaded.
*/
main(): void | Promise<void>;
}
export interface ContentScriptBaseDefinition extends ExcludableEntrypoint {
export interface BaseContentScriptEntrypointOptions
extends BaseEntrypointOptions {
matches: PerBrowserOption<Manifest.ContentScript['matches']>;
/**
* See https://developer.chrome.com/docs/extensions/mv3/content_scripts/
@@ -581,44 +531,207 @@ export interface ContentScriptBaseDefinition extends ExcludableEntrypoint {
* onto the page. Use `browser.runtime.getURL("content-scripts/<name>.css")` to get the file's
* URL
* - `"ui"` - Exclude the CSS from the manifest. CSS will be automatically added to your UI when
* calling `createContentScriptUi`
* calling `createShadowRootUi`
*
* @default "manifest"
*/
cssInjectionMode?: PerBrowserOption<'manifest' | 'manual' | 'ui'>;
/**
* Specify how the content script is registered.
*
* - `"manifest"`: The content script will be added to the `content_scripts` entry in the
* manifest. This is the normal and most well known way of registering a content script.
* - `"runtime"`: The content script's `matches` is added to `host_permissions` and you are
* responsible for using the scripting API to register the content script dynamically at
* runtime.
*
* @default "manifest"
*/
registration?: PerBrowserOption<'manifest' | 'runtime'>;
}
export interface BackgroundDefinition extends ExcludableEntrypoint {
type?: PerBrowserOption<'module'>;
persistent?: PerBrowserOption<boolean>;
export interface MainWorldContentScriptEntrypointOptions
extends BaseContentScriptEntrypointOptions {
/**
* See https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts#isolated_world
*/
world: 'MAIN';
}
export interface IsolatedWorldContentScriptEntrypointOptions
extends BaseContentScriptEntrypointOptions {
/**
* See https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts#isolated_world
* @default "ISOLATED"
*/
world?: 'ISOLATED';
}
export interface PopupEntrypointOptions extends BaseEntrypointOptions {
/**
* Defaults to "browser_action" to be equivalent to MV3's "action" key
*/
mv2Key?: PerBrowserOption<'browser_action' | 'page_action'>;
defaultIcon?: Record<string, string>;
defaultTitle?: PerBrowserOption<string>;
browserStyle?: PerBrowserOption<boolean>;
}
export interface OptionsEntrypointOptions extends BaseEntrypointOptions {
openInTab?: PerBrowserOption<boolean>;
browserStyle?: PerBrowserOption<boolean>;
chromeStyle?: PerBrowserOption<boolean>;
}
export interface SidepanelEntrypointOptions extends BaseEntrypointOptions {
/**
* Firefox only. See https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/sidebar_action#syntax
* @default false
*/
openAtInstall?: PerBrowserOption<boolean>;
/**
* @deprecated See https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/sidebar_action#syntax
*/
browserStyle?: PerBrowserOption<boolean>;
defaultIcon?: string | Record<string, string>;
defaultTitle?: PerBrowserOption<string>;
}
export interface BaseEntrypoint {
/**
* The entrypoint's name. This is the filename or dirname without the type suffix.
*
* Examples:
* - `popup.html` &rarr; `popup`
* - `options/index.html` &rarr; `options`
* - `named.sandbox.html` &rarr; `named`
* - `named.sandbox/index.html` &rarr; `named`
* - `sandbox.html` &rarr; `sandbox`
* - `sandbox/index.html` &rarr; `sandbox`
* - `overlay.content.ts` &rarr; `overlay`
* - `overlay.content/index.ts` &rarr; `overlay`
*
* The name is used when generating an output file:
* `<entrypoint.outputDir>/<entrypoint.name>.<ext>`
*/
name: string;
/**
* Absolute path to the entrypoint's input file.
*/
inputPath: string;
/**
* Absolute path to the entrypoint's output directory. Can be the`InternalConfg.outDir` or a
* subdirectory of it.
*/
outputDir: string;
skipped: boolean;
}
export interface GenericEntrypoint extends BaseEntrypoint {
type:
| 'sandbox'
| 'bookmarks'
| 'history'
| 'newtab'
| 'devtools'
| 'unlisted-page'
| 'unlisted-script'
| 'unlisted-style'
| 'content-script-style';
options: ResolvedPerBrowserOptions<BaseEntrypointOptions>;
}
export interface BackgroundEntrypoint extends BaseEntrypoint {
type: 'background';
options: ResolvedPerBrowserOptions<BackgroundEntrypointOptions>;
}
export interface ContentScriptEntrypoint extends BaseEntrypoint {
type: 'content-script';
options: ResolvedPerBrowserOptions<
| MainWorldContentScriptEntrypointOptions
| IsolatedWorldContentScriptEntrypointOptions
>;
}
export interface PopupEntrypoint extends BaseEntrypoint {
type: 'popup';
options: ResolvedPerBrowserOptions<PopupEntrypointOptions, 'defaultIcon'>;
}
export interface OptionsEntrypoint extends BaseEntrypoint {
type: 'options';
options: ResolvedPerBrowserOptions<OptionsEntrypointOptions>;
}
export interface SidepanelEntrypoint extends BaseEntrypoint {
type: 'sidepanel';
options: ResolvedPerBrowserOptions<SidepanelEntrypointOptions, 'defaultIcon'>;
}
export type Entrypoint =
| GenericEntrypoint
| BackgroundEntrypoint
| ContentScriptEntrypoint
| PopupEntrypoint
| OptionsEntrypoint
| SidepanelEntrypoint;
export type EntrypointGroup = Entrypoint | Entrypoint[];
export type OnContentScriptStopped = (cb: () => void) => void;
export interface IsolatedWorldContentScriptDefinition
extends IsolatedWorldContentScriptEntrypointOptions {
/**
* Main function executed when the content script is loaded.
*/
main(ctx: ContentScriptContext): void | Promise<void>;
}
export interface MainWorldContentScriptDefinition
extends MainWorldContentScriptEntrypointOptions {
/**
* Main function executed when the content script is loaded.
*/
main(): void | Promise<void>;
}
export type ContentScriptDefinition =
| IsolatedWorldContentScriptDefinition
| MainWorldContentScriptDefinition;
export interface BackgroundDefinition extends BackgroundEntrypointOptions {
/**
* Main function executed when the background script is started. Cannot be async.
*/
main(): void;
}
export interface UnlistedScriptDefinition extends ExcludableEntrypoint {
export interface UnlistedScriptDefinition extends BaseEntrypointOptions {
/**
* Main function executed when the unlisted script is ran.
*/
main(): void | Promise<void>;
}
export type PerBrowserOption<T> = T | { [browser: TargetBrowser]: T };
/**
* Either a single value or a map of different browsers to the value for that browser.
*/
export type PerBrowserOption<T> = T | PerBrowserMap<T>;
export type PerBrowserMap<T> = { [browser: TargetBrowser]: T };
export interface ExcludableEntrypoint {
/**
* List of target browsers to include this entrypoint in. Defaults to being included in all
* builds. Cannot be used with `exclude`. You must choose one of the two options.
*
* @default undefined
*/
include?: TargetBrowser[];
/**
* List of target browsers to exclude this entrypoint from. Cannot be used with `include`. You
* must choose one of the two options.
*
* @default undefined
*/
exclude?: TargetBrowser[];
}
/**
* Convert `{ key: PerBrowserOption<T> }` to just `{ key: T }`, stripping away the
* `PerBrowserOption` type for all fields inside the object.
*
* A optional second list of keys can be passed if a field isn't compatible with `PerBrowserOption`, like `defaultIcon`.
*/
export type ResolvedPerBrowserOptions<T, TOmitted extends keyof T = never> = {
[key in keyof Omit<T, TOmitted>]: T[key] extends PerBrowserOption<infer U>
? U
: T[key];
} & { [key in TOmitted]: T[key] };
/**
* Manifest customization available in the `wxt.config.ts` file. You cannot configure entrypoints
@@ -634,8 +747,6 @@ export type UserManifest = Partial<
| 'options_page'
| 'options_ui'
| 'sandbox'
| 'sidepanel'
| 'sidebar_action'
>
>;
@@ -644,14 +755,23 @@ export type UserManifestFn = (
) => UserManifest | Promise<UserManifest>;
export interface ConfigEnv {
/**
* The build mode passed into the CLI. By default, `wxt` uses `"development"` and `wxt build|zip`
* uses `"production"`.
*/
mode: string;
/**
* The command used to run WXT. `"serve"` during development and `"build"` for any other command.
*/
command: 'build' | 'serve';
/**
* Browser passed in from the CLI
* Browser passed in from the CLI via the `-b` or `--browser` flag. Defaults to `"chrome"` when not passed.
*/
browser: TargetBrowser;
/**
* Manifest version passed in from the CLI
* Manifest version passed in from the CLI via the `--mv2` or `--mv3` flags. When not passed, it depends on the target browser. See
* [the guide](https://wxt.dev/guide/multiple-browsers.html#target-manifest-version) for more
* details.
*/
manifestVersion: 2 | 3;
}
@@ -865,6 +985,10 @@ export interface Wxt {
* Reload config file and update the `config` field with the result.
*/
reloadConfig: () => Promise<void>;
/**
* Package manager utilities.
*/
pm: WxtPackageManager;
}
export interface ResolvedConfig {
@@ -878,13 +1002,17 @@ export interface ResolvedConfig {
outBaseDir: string;
outDir: string;
debug: boolean;
/**
* Directory pointing to `node_modules/wxt`, wherever WXT is installed.
*/
wxtModuleDir: string;
mode: string;
command: 'build' | 'serve';
browser: TargetBrowser;
manifestVersion: TargetManifestVersion;
env: ConfigEnv;
logger: Logger;
imports: false | Partial<UnimportOptions>;
imports: false | WxtResolvedUnimportOptions;
manifest: UserManifest;
fsCache: FsCache;
server?: WxtDevServer;
@@ -896,13 +1024,20 @@ export interface ResolvedConfig {
includeSources: string[];
excludeSources: string[];
sourcesRoot: string;
downloadedPackagesDir: string;
downloadPackages: string[];
};
transformManifest: (manifest: Manifest.WebExtensionManifest) => void;
analysis: {
enabled: boolean;
template: NonNullable<PluginVisualizerOptions['template']>;
/** Absolute file path */
/** Absolute file path to the `stats.html` file */
outputFile: string;
/** The directory where the final `stats.html` file is located */
outputDir: string;
/** Name of the `stats.html` file, minus ".html" */
outputName: string;
keepArtifacts: boolean;
};
userConfigMetadata: Omit<C12ResolvedConfig<UserConfig>, 'config'>;
/**
@@ -934,3 +1069,89 @@ export type VirtualEntrypointType =
| 'content-script-isolated-world'
| 'background'
| 'unlisted-script';
export type EslintGlobalsPropValue =
| boolean
| 'readonly'
| 'readable'
| 'writable'
| 'writeable';
export interface Eslintrc {
/**
* When true, generates a file that can be used by ESLint to know which variables are valid globals.
*
* - `'auto'`: Check if eslint is installed, and if it is, generate the helper file
* - `true`: Generate the helper file
* - `false`: Don't generate the file
*
* @default 'auto'
*/
enabled?: boolean | 'auto';
/**
* File path to save the generated eslint config.
*
* @default './.wxt/eslintrc-auto-import.json'
*/
filePath?: string;
/**
* @default true
*/
globalsPropValue?: EslintGlobalsPropValue;
}
export interface ResolvedEslintrc {
enabled: boolean;
/** Absolute path */
filePath: string;
globalsPropValue: EslintGlobalsPropValue;
}
export type WxtUnimportOptions = Partial<UnimportOptions> & {
/**
* When eslint is installed,
*/
eslintrc?: Eslintrc;
};
export type WxtResolvedUnimportOptions = Partial<UnimportOptions> & {
eslintrc: ResolvedEslintrc;
};
/**
* Package management utils built on top of [`nypm`](https://www.npmjs.com/package/nypm)
*/
export interface WxtPackageManager extends Nypm.PackageManager {
addDependency: typeof Nypm.addDependency;
addDevDependency: typeof Nypm.addDevDependency;
ensureDependencyInstalled: typeof Nypm.ensureDependencyInstalled;
installDependencies: typeof Nypm.installDependencies;
removeDependency: typeof Nypm.removeDependency;
/**
* Download a package's TGZ file and move it into the `downloadDir`. Use's `npm pack <name>`, so
* you must have setup authorization in `.npmrc` file, regardless of the package manager used.
*
* @param id Name of the package to download, can include a version (like `wxt@0.17.1`)
* @param downloadDir Where to store the package.
* @returns Absolute path to downloaded file.
*/
downloadDependency: (id: string, downloadDir: string) => Promise<string>;
/**
* Run `npm ls`, `pnpm ls`, or `bun pm ls`, or `yarn list` and return the results.
*
* WARNING: Yarn always returns all dependencies
*/
listDependencies: (options?: {
cwd?: string;
all?: boolean;
}) => Promise<Dependency[]>;
/**
* Key used to override package versions. Sometimes called "resolutions".
*/
overridesKey: string;
}
export interface Dependency {
name: string;
version: string;
}
-12
View File
@@ -50,18 +50,6 @@ declare module 'web-ext-run/util/logger' {
export const consoleStream: IConsoleStream;
}
declare module 'manage-path' {
export interface ManagedPath {
push(...paths: string[]);
push(paths: string[]);
shift(...paths: string[]);
shift(paths: string[]);
get(): string;
restore(): void;
}
export default function managePath(env: object): ManagedPath;
}
declare module 'wxt/browser' {
// Overridden when types are generated per project
export type PublicPath = string;
+1 -1
View File
@@ -39,7 +39,7 @@ try {
// @ts-expect-error: res shouldn't be a promise, but we're checking it anyways
if (res instanceof Promise) {
console.warn(
"The background's main() function return a promise, but it must be synchonous",
"The background's main() function return a promise, but it must be synchronous",
);
}
} catch (err) {
+53 -5
View File
@@ -7,17 +7,34 @@ interface ContentScript {
js?: string[];
css?: string[];
}
interface ReloadContentScriptPayload {
registration?: 'manifest' | 'runtime';
contentScript: ContentScript;
}
export function reloadContentScript(contentScript: ContentScript) {
export function reloadContentScript(payload: ReloadContentScriptPayload) {
const manifest = browser.runtime.getManifest();
if (manifest.manifest_version == 2) {
void reloadContentScriptMv2(contentScript);
void reloadContentScriptMv2(payload);
} else {
void reloadContentScriptMv3(contentScript);
void reloadContentScriptMv3(payload);
}
}
export async function reloadContentScriptMv3(contentScript: ContentScript) {
export async function reloadContentScriptMv3({
registration,
contentScript,
}: ReloadContentScriptPayload) {
if (registration === 'runtime') {
await reloadRuntimeContentScriptMv3(contentScript);
} else {
await reloadManifestContentScriptMv3(contentScript);
}
}
export async function reloadManifestContentScriptMv3(
contentScript: ContentScript,
) {
const id = `wxt:${contentScript.js![0]}`;
logger.log('Reloading content script:', contentScript);
const registered = await browser.scripting.getRegisteredContentScripts();
@@ -33,6 +50,35 @@ export async function reloadContentScriptMv3(contentScript: ContentScript) {
await browser.scripting.registerContentScripts([{ ...contentScript, id }]);
}
await reloadTabsForContentScript(contentScript);
}
export async function reloadRuntimeContentScriptMv3(
contentScript: ContentScript,
) {
logger.log('Reloading content script:', contentScript);
const registered = await browser.scripting.getRegisteredContentScripts();
logger.debug('Existing scripts:', registered);
const matches = registered.filter((cs) => {
const hasJs = contentScript.js?.find((js) => cs.js?.includes(js));
const hasCss = contentScript.css?.find((css) => cs.css?.includes(css));
return hasJs || hasCss;
});
if (matches.length === 0) {
logger.log(
'Content script is not registered yet, nothing to reload',
contentScript,
);
return;
}
await browser.scripting.updateContentScripts(matches);
await reloadTabsForContentScript(contentScript);
}
async function reloadTabsForContentScript(contentScript: ContentScript) {
const allTabs = await browser.tabs.query({});
const matchPatterns = contentScript.matches.map(
(match) => new MatchPattern(match),
@@ -45,6 +91,8 @@ export async function reloadContentScriptMv3(contentScript: ContentScript) {
await Promise.all(matchingTabs.map((tab) => browser.tabs.reload(tab.id)));
}
export async function reloadContentScriptMv2(contentScript: ContentScript) {
export async function reloadContentScriptMv2(
_payload: ReloadContentScriptPayload,
) {
throw Error('TODO: reloadContentScriptMv2');
}
+1
View File
@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
.output
stats.html
stats-*.json
.wxt
web-ext.config.ts
+6
View File
@@ -0,0 +1,6 @@
export default defineContentScript({
matches: ['*://*.google.com/*'],
main() {
console.log('Hello content.');
},
});
+1
View File
@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
.output
stats.html
stats-*.json
.wxt
web-ext.config.ts
+6
View File
@@ -0,0 +1,6 @@
export default defineContentScript({
matches: ['*://*.google.com/*'],
main() {
console.log('Hello content.');
},
});

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