Compare commits

...

155 Commits

Author SHA1 Message Date
GitHub Actions ba3d8838e3 chore(release): @wxt-dev/module-svelte v2.0.3
vhs / vhs (push) Waiting to run
2025-01-28 21:17:33 +00:00
Eli e9b6f5f97c fix: Upgrade vite-plugin-svelte to support Vite 6 (#1375)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2025-01-28 15:09:01 -06:00
Aaron 5b6c82d38f chore: Fix formatting 2025-01-28 14:43:38 -06:00
Aaron 5b41b70abe docs: Add SECURITY.md 2025-01-28 14:41:56 -06:00
Okinea Dev 93f624610f chore: add pull request template (#1372)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2025-01-28 08:03:26 -06:00
Okinea Dev 73bdd49471 chore: simplify FUNDING.yml (#1371) 2025-01-28 07:32:59 -06:00
GitHub Actions 635f3f0898 chore(release): wxt v0.19.26
vhs / vhs (push) Waiting to run
2025-01-28 06:46:49 +00:00
Deniz Uğur a53ee6d590 fix(context): Deduplicate wxt:content-script-started (#1364)
Co-authored-by: DenizUgur <DenizUgur@users.noreply.github.com>
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2025-01-28 00:41:12 -06:00
GitHub Actions 665b9194cf chore(release): @wxt-dev/storage v1.1.0
vhs / vhs (push) Waiting to run
2025-01-28 06:40:20 +00:00
Gabriel Roldán 4690418c94 Fix: Remove duplicated css rule from vue template (#1369) 2025-01-27 16:00:09 -06:00
Chengxi 20eea2a7d1 feat: Add storage.clear (#1368)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2025-01-27 07:22:19 -06:00
rxliuli ac7b6760f2 docs: Add missing bracket in example (#1367) 2025-01-25 01:14:48 -06:00
GitHub Actions a593cfbbb6 chore(release): wxt v0.19.25
vhs / vhs (push) Waiting to run
2025-01-22 05:08:46 +00:00
Aaron a205f2a0a8 chore: Update lockfile 2025-01-21 23:03:09 -06:00
Aaron 8e2badc92b fix: Drop support for vite 6.0.9+ until websocket fix is finished 2025-01-21 23:02:22 -06:00
redwoodlid 7c18d9c0b8 docs: Add react language ID to README (#1347) 2025-01-15 10:25:40 -06:00
Aaron 55a1f39450 Update changelog 2025-01-15 10:23:36 -06:00
GitHub Actions d708eca78e chore(release): wxt v0.19.24
vhs / vhs (push) Waiting to run
2025-01-12 22:48:50 +00:00
1natsu 5d7e2516b6 fix: autoMount not working if anchor is already present (#1350) 2025-01-12 16:43:07 -06:00
Eric fb6a29872b docs: Add "HTML to Markdown" to the homepage (#1345) 2025-01-10 11:28:27 -06:00
rxliuli 187206062e docs: Update content script UI examples to use Svelte v5 (#1342) 2025-01-07 23:51:46 -06:00
Aaron 2615e29e52 docs: Add section to FAQ about teleports/portals 2025-01-07 23:37:53 -06:00
Aaron 8ca176d37e docs: Update i18n.md 2025-01-06 17:40:57 -06:00
Aaron cb2bf43f30 docs: Update content-scripts.md 2025-01-06 17:36:43 -06:00
Aaron 1719d17cff docs: Fix typo 2025-01-06 17:28:31 -06:00
Aaron 32afb8d97c docs: Improve manifest docs, mention entrypoint-specific options 2025-01-06 17:28:09 -06:00
Aaron 72b8a29d76 docs: Improve entrypoint docs 2025-01-06 17:09:20 -06:00
Aaron af0a32d478 docs: Add missing tabs in project structure 2025-01-06 16:59:23 -06:00
Nikolay Ponomarev 060e85f90e docs: Add missing web_accessible_resources to injectScript example (#1337) 2025-01-06 16:23:41 -06:00
Aaron 654a54ac79 docs: Update link 2025-01-05 10:14:49 -06:00
ntnyq ef24358942 docs: Fix unocss readme (#1329)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2025-01-04 17:01:43 -06:00
Aaron e5e67e39bd docs: Add paragraph on browser binary auto-discovery 2025-01-01 17:23:04 -06:00
Ahmed Dinar 228304bef9 docs: Added "Blync" to the homepage (#1319) 2025-01-01 08:56:07 -06:00
Paulo Pinto 78f97f32a2 fix(web-ext): Correctly pass browserConsole option to open devtools in Firefox (#1308) 2024-12-29 12:35:38 -06:00
Abhigyan Trips 459662a6c1 docs: Added "ZenGram" to the homepage (#1313) 2024-12-29 12:26:14 -06:00
Aaron b150a52471 fix(init): Remove "experimental" from bun package manager 2024-12-25 12:52:13 -06:00
Aaron 028c601515 chore: Fix typo in CLI docs 2024-12-25 12:51:41 -06:00
Aaron af382ef065 Fix changelog 2024-12-25 09:47:41 -06:00
GitHub Actions 91ef7f2ab5 chore(release): wxt v0.19.23
vhs / vhs (push) Waiting to run
2024-12-25 15:44:50 +00:00
Aaron 0bf7279c8f fix: Properly close readline instance on close (#1278) 2024-12-25 09:40:39 -06:00
Aaron 62525d33c6 fix: Allow runtime registered content scripts to not have matches (#1306) 2024-12-25 09:39:37 -06:00
Aaron 88ff40e301 docs: Fix typo in FAQ 2024-12-23 09:09:08 -06:00
Aaron 1a72a0cdc6 docs: Add paragraph on adding permissions 2024-12-22 09:14:31 -06:00
Amit Yadav 2b07b4bc12 docs: Fix invalid CSS color (#1299) 2024-12-22 09:09:49 -06:00
Abhigyan Trips 06c09e7c87 feat(hooks): Move entrypoints:resolved before debug logs and add entrypoints:found (#1292)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2024-12-20 21:09:31 -06:00
Amit Yadav 30b96ac87c docs: Added "Strong Password Generator" to the homepage (#1290) 2024-12-19 00:20:09 -06:00
Amit Yadav 2e9b5cffe8 docs: Added "Show IP Extension" to the homepage (#1289) 2024-12-18 17:13:11 -06:00
Aaron 3e1641a6ec docs: Add more comments on entrypoint options 2024-12-17 14:05:37 -06:00
GitHub Actions d64ca52bb0 chore(release): @wxt-dev/module-react v1.1.3
vhs / vhs (push) Waiting to run
2024-12-17 19:07:47 +00:00
Aaron 8c645fa1ce fix: Use react 19 in @wxt-dev/module-react and templates/react (#1285) 2024-12-17 12:57:50 -06:00
Nishu 79c9c89923 chore: Upgrade React to v19 in demo (#1280) 2024-12-17 12:30:48 -06:00
Aaron 11d93fe724 docs: Add pkg.pr.new docs to CONTRIBUTING.md 2024-12-17 12:05:47 -06:00
Aaron 039c561a80 chore: Simplify keyboard shortcuts (#1284) 2024-12-17 11:55:50 -06:00
GitHub Actions 64a704fc72 chore(release): @wxt-dev/module-svelte v2.0.2
vhs / vhs (push) Waiting to run
2024-12-17 16:41:07 +00:00
Aaron 4fbf137d15 fix: Svelte production errors and warnings on resolve conditions (#1283) 2024-12-17 10:35:39 -06:00
Luca Dalli ff065ab88b docs: Added "Cursorful" to the homepage (#1282) 2024-12-17 10:29:43 -06:00
Aaron 7fdf3fb968 docs: Add missing brackets to example 2024-12-16 16:12:00 -06:00
Aaron 7ebd451409 docs: Document how to use WASM 2024-12-16 16:07:16 -06:00
Aaron a7a48a8a75 docs: Add sponsors to wxt.dev homepage 2024-12-16 14:38:30 -06:00
Aaron 27298b7f22 docs: Sync READMEs 2024-12-16 14:35:16 -06:00
Aaron 565ce93a9a docs: Add sponsors section to README 2024-12-16 14:32:46 -06:00
Aaron 0bc6088b6a chore: Fix formatting 2024-12-16 13:48:41 -06:00
Aaron 2fb99e8fbb docs: Add netlify badge to homepage 2024-12-16 13:48:17 -06:00
Aaron cd2050c534 chore: Add code of conduct 2024-12-16 11:25:19 -06:00
aklinker1 5b557d0224 docs: Update wxt init GIF 2024-12-13 00:38:13 +00:00
Aaron 0137d79ba0 docs: Update colors to improve contrast ratio (#1275)
Co-authored-by: aklinker1 <aklinker1@users.noreply.github.com>
2024-12-12 18:36:24 -06:00
GitHub Actions e05fbf4fb5 chore(release): wxt v0.19.22
vhs / vhs (push) Waiting to run
2024-12-10 23:11:47 +00:00
Aaron 72e6ca5063 fix: Exclude entire import.meta.env object from content script output (#1267) 2024-12-10 17:06:25 -06:00
GitHub Actions af9e842fe0 chore(release): wxt v0.19.21
vhs / vhs (push) Waiting to run
2024-12-10 18:05:17 +00:00
Aaron 230962686f fix: Exclude skipped entrypoints from manifest (#1265) 2024-12-10 11:59:44 -06:00
GitHub Actions 413cd9354f chore(release): wxt v0.19.20
vhs / vhs (push) Waiting to run
2024-12-09 20:12:43 +00:00
不游 64a42de31f docs: fix colored-diff in es-modules.md (#1264) 2024-12-09 07:42:54 -06:00
Aaron f7f03e2669 fix: Import entrypoint improvements (#1207) 2024-12-08 12:10:05 -06:00
Aaron 1dc7c34bfd fix: Only show reopen browser shortcut when it can be done (#1263) 2024-12-08 08:55:01 -06:00
Aaron 0fd5c3846c docs: Fix gramar 2024-12-07 08:57:17 -06:00
Aaron e42e92f9df docs: Update "Using WXT" instructions 2024-12-07 08:56:13 -06:00
Kepi e45c91f941 docs: Add "EyeDropper" to homepage (#1260)
Co-authored-by: Aaron Klinker <aaronklinker1@gmail.com>
2024-12-07 08:47:33 -06:00
1natsu 8f71d80f94 feat: autoMount content script UIs (#1210)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2024-12-07 08:21:52 -06:00
Aaron Klinker 78e896e5fb docs: Remove duplicate section from upgrade docs 2024-12-07 01:12:23 -06:00
GitHub Actions a643852387 chore(release): wxt v0.19.19
vhs / vhs (push) Waiting to run
2024-12-06 00:28:20 +00:00
Nishu e221252325 fix: Exclude skipped entrypoints from Firefox sources zip (#1238)
Co-authored-by: Aaron Klinker <aaronklinker1@gmail.com>
2024-12-05 18:23:00 -06:00
Aaron 88f126e658 fix: Ensure content scripts are registered immediately in dev mode (#1253) 2024-12-05 18:16:38 -06:00
Aaron 17af05acc8 fix: Prevent changing dev server port when reloading config (#1241) 2024-12-05 11:48:49 -06:00
Jeremie Corpinot fd436a0d63 docs: Add "aesthetic Notion, styled" to homepage (#1247) 2024-12-04 16:00:23 -06:00
Nishu 0d796b63c2 feat: Keyboard shortcut to reopen the browser without restarting the dev command (#1211)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
Co-authored-by: Florian Metz <me@timeraa.dev>
2024-12-03 21:42:44 -06:00
GitHub Actions cb143d2bbc chore(release): wxt v0.19.18
vhs / vhs (push) Waiting to run
2024-12-03 23:00:59 +00:00
Aaron f3e7758521 types: Add overloads to ContentScriptContext#addEventListener for different targets (#1245) 2024-12-03 16:55:33 -06:00
Aaron 23ef101954 chore: Refactor findEntrypoints to return all entrypoints with skipped set properly (#1244) 2024-12-03 12:36:11 -06:00
spookyuser c2f5efbde4 docs: Update injectScript docs to match example from examples repo (#1240)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2024-12-03 12:23:33 -06:00
Takuya HARA 4ce178c592 docs: Changed "Chrome Extension" to "web extension" (#1229)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2024-12-03 11:07:46 -06:00
dependabot[bot] 7aa30afa7f chore(deps): bump codecov/codecov-action from 4 to 5 (#1237)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-01 16:13:47 -06:00
GitHub Actions 90aaed202c chore(release): @wxt-dev/module-svelte v2.0.1
vhs / vhs (push) Waiting to run
2024-12-01 15:33:52 +00:00
Aaron c92ed13d66 fix: Modify dev vite.resolve.conditions to support Vite 6 + Svelte 5 (#1230) 2024-12-01 09:28:30 -06:00
Jaguar Zhou 0e4953fbfc fix: correct dev.reloadCommand restriction to consider only commands with suggested keys (#1226) 2024-11-29 12:07:09 -06:00
roechsli 799d687061 docs: Add "QA Compass" to homepage (#1223) 2024-11-28 10:34:20 -06:00
Aaron 866eb0694c Cleanup changelog 2024-11-28 08:48:40 -06:00
GitHub Actions e5723aab03 chore(release): wxt v0.19.17
vhs / vhs (push) Waiting to run
2024-11-28 14:42:49 +00:00
Jaguar Zhou f51f5dd19f doc: remove dead links generated by typedoc (#1222) 2024-11-28 08:33:11 -06:00
Aaron aae5f0789d fix: Make content script matches optional (#1220) 2024-11-28 00:00:18 -06:00
1natsu 586fe3950c fix: Correctly remove child elements with integrated UI remove (#1219) 2024-11-27 22:39:25 -06:00
Aaron 241c907c65 fix: Automatically convert MV3 content_security_policy to MV2 (#1168)
Co-authored-by: windmillcode0 <michaelodumosu57@gmail.com>
Co-authored-by: windmillcode0 <shieldmousetower734@gmail.com>
2024-11-27 10:14:32 -06:00
Aaron aab4244d84 fix: Update vite dependency range to support v6 (#1215) 2024-11-26 14:26:11 -06:00
Aaron 767a3a987c chore(vue-template): Fix incompatible typescript and vue-tsc versions (#1208) 2024-11-25 15:25:42 -06:00
Aaron a2924d872a fix: Revert "Validate entrypoints after hooks have modified them (54baf21)"
This reverts commit 54baf21490.
2024-11-25 15:08:55 -06:00
Aaron 54baf21490 fix: Validate entrypoints after hooks have modified them 2024-11-25 09:29:46 -06:00
Nishu 5a3790a7d3 fix: ESLint config being generated when ESLint is not installed. (#1198)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2024-11-22 08:54:05 -06:00
Aaron 236e099b81 docs: Update windows config for persisting browser profile 2024-11-22 08:53:16 -06:00
GitHub Actions 9b54bc4704 chore(release): @wxt-dev/storage v1.0.1
vhs / vhs (push) Waiting to run
2024-11-22 14:41:52 +00:00
Aaron ffb99af719 fix: use browser for mv2 storage (#1200) 2024-11-22 08:36:29 -06:00
Jaguar Zhou 90bb145d91 chore(vue-template): remove references to Vetur and TypeScript Vue Plugin (Volar) (#1197) 2024-11-22 08:21:06 -06:00
Mira Dobrovolskaya 190c87d120 docs: fix typo in entrypoints options example (#1194) 2024-11-21 11:15:10 -06:00
Tran Ngoc Tan 4c511d5728 docs: Add "YouTube Hider" to homepage (#1193) 2024-11-21 03:07:45 +01:00
Florian Metz b8ecbc5a54 docs: fix analyze typo in type (#1187) 2024-11-20 08:46:42 -06:00
Nithur 8c3d756187 docs: Add "Wandpen" to homepage (#1191)
Co-authored-by: Florian Metz <me@timeraa.dev>
2024-11-20 10:18:15 +01:00
Patato cfdc6b3e6b chore: Update Svelte template to Svelte 5 (#1184)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2024-11-17 14:58:09 -06:00
Aaron 226e6c9fbe docs: Add FAQ around configuring browser startup 2024-11-16 11:33:58 -06:00
Aaron db41f27072 feat: New server:created, server:started, and server:closed hooks (#1179) 2024-11-16 09:29:32 -06:00
Aaron db025cd3a2 fix: Re-initialize WXT modules correctly during development (#1176) 2024-11-16 08:56:08 -06:00
GitHub Actions 57229c69c1 chore(release): @wxt-dev/module-solid v1.1.3
vhs / vhs (push) Waiting to run
2024-11-15 15:46:51 +00:00
GitHub Actions 8f3a90eeb2 chore(release): @wxt-dev/module-react v1.1.2
vhs / vhs (push) Waiting to run
2024-11-15 15:38:36 +00:00
GitHub Actions a360965d92 chore(release): @wxt-dev/module-vue v1.0.2
vhs / vhs (push) Waiting to run
2024-11-15 15:28:53 +00:00
Aaron eb1ace6d20 fix: Use config:resolved hook to update config instead of ready (#1178) 2024-11-15 09:08:14 -06:00
GitHub Actions 497c902283 chore(release): wxt v0.19.16
vhs / vhs (push) Waiting to run
2024-11-15 14:57:29 +00:00
Aaron 4d4453b830 feat(hooks): Add new config:resolved hook (#1177) 2024-11-15 08:52:22 -06:00
GitHub Actions ef53580ed1 chore(release): wxt v0.19.15
vhs / vhs (push) Waiting to run
2024-11-14 16:41:09 +00:00
Aaron 52e5387d47 fix: Add extension ID to event used to invalidate ContentScriptContext (#1175) 2024-11-14 10:30:51 -06:00
Aaron f5b7f7e0e5 docs: Cleanup changelog 2024-11-14 10:11:39 -06:00
Aaron 592bdbe4d9 chore: Speed up sync-releases workflow by not building packages 2024-11-14 10:09:40 -06:00
Aaron 6fc227bad6 chore: Init changelog for storage package 2024-11-14 10:03:55 -06:00
Aaron d74f6b0735 feat: Extract wxt/storage to its own package, @wxt-dev/storage (#1129)
vhs / vhs (push) Waiting to run
2024-11-14 10:00:08 -06:00
dsrrlldr 62ea796045 docs: Add "Invoice Generator" and "Monthly Bill Tracker" to homepage (#1174) 2024-11-14 08:01:11 -06:00
Nishu 064704c9b7 fix: Add "/" to PublicPath and browser.runtime.getURL (#1171)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2024-11-14 02:02:00 -06:00
Qiwei Yang c846a1febf docs: Add "Text Search Pro" to homepage (#1172) 2024-11-14 01:07:53 -06:00
Aaron c3959e8427 fix: Respect configOrPath for dev server (#1169) 2024-11-13 16:25:03 -06:00
GitHub Actions e770f9b6b7 chore(release): wxt v0.19.14
vhs / vhs (push) Waiting to run
2024-11-13 15:06:31 +00:00
Aaron 67f35615a2 chore(deps): Bump dev and non-breaking major dependencies (#1167) 2024-11-13 08:32:29 -06:00
Aaron 5c7b6098a2 chore(deps): Upgrade all non-major dependencies (#1164) 2024-11-13 08:19:40 -06:00
Willow f7d12b7486 docs: add missing cd command (#1163) 2024-11-13 08:08:32 -06:00
rxliuli 3481313859 feat: automatically disable 'Show warning about Self-XSS when pasing code' in new chrome (#1159) 2024-11-13 08:07:37 -06:00
Aaron 2f23556893 Update changelog 2024-11-13 08:07:15 -06:00
GitHub Actions 330e39159a chore(release): @wxt-dev/module-svelte v2.0.0
vhs / vhs (push) Waiting to run
2024-11-13 14:04:41 +00:00
kongmoumou aa14676f91 fix: Throw when config file does not exist (#1156)
Co-authored-by: kongmoumou <kongmoumou@users.noreply.github.com>
2024-11-13 07:59:49 -06:00
Goran Nikic 199f330324 docs: Add "propbar.co.uk" to homepage (#1149)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2024-11-13 07:59:14 -06:00
jasonbla 63dee97ac3 docs: fix incorrect syntax for MatchPattern (#1148) 2024-11-13 07:56:58 -06:00
nirtamir2 b121ed2fe2 docs: Fix solid-js example (#1145) 2024-11-13 07:56:34 -06:00
Johnson Towoju 607e1162e5 docs: Add "YouTube Simple View" to homepage (#1142)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2024-11-13 07:55:41 -06:00
dsrrlldr b4d569a915 docs: Add "Keyword Rank Checker" to homepage (#1141) 2024-11-13 07:52:30 -06:00
Aaron 4fac364542 feat!: Svelte 5 support (#1104) 2024-11-13 07:48:57 -06:00
Aaron 51c7ee5fa8 chore: Update FUNDING.yml 2024-11-11 17:19:20 -06:00
Aaron 9b780f2d00 docs: Add FAQ about component library styles in content scripts 2024-11-11 16:36:04 -06:00
Aaron f5aec7ec9b chore: Create FUNDING.yml 2024-11-11 15:29:36 -06:00
Florian Metz 5d991eda31 feat(storage): Support storage items in batch functions (#990)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2024-10-30 23:36:24 -05:00
Bread Grocery 0318a34046 docs: Fix typo in popup and options EntrypointOptions (#1121) 2024-10-29 08:05:13 -05:00
1natsu e7c66304d3 chore: add "@types/chrome" to templates (#1119) 2024-10-28 14:33:21 -05:00
Ayub Kokabi 4603ebb511 docs: Add missing closing bracket (#1112)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2024-10-28 14:33:10 -05:00
122 changed files with 5910 additions and 2533 deletions
+3
View File
@@ -0,0 +1,3 @@
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository#about-funding-files
github: wxt-dev
+4 -1
View File
@@ -4,6 +4,9 @@ inputs:
install:
default: 'true'
description: Whether or not to run 'pnpm install'
installArgs:
default: ''
description: Additional args to append to "pnpm install"
runs:
using: composite
steps:
@@ -17,4 +20,4 @@ runs:
- name: Install Dependencies
if: ${{ inputs.install == 'true' }}
shell: bash
run: pnpm install
run: pnpm install ${{ inputs.installArgs }}
+13
View File
@@ -0,0 +1,13 @@
### Overview
<!-- Describe your changes and why you made them -->
### Manual Testing
<!-- Describe how to test your changes to make sure the PR works as intended -->
### Related Issue
<!-- If this PR is related to an issue, please link it here -->
This PR closes #<issue_number>
+1
View File
@@ -13,6 +13,7 @@ on:
- module-solid
- module-svelte
- module-vue
- storage
- unocss
- wxt
+8 -5
View File
@@ -7,13 +7,14 @@ on:
default: wxt
type: choice
options:
- wxt
- module-react
- module-vue
- module-svelte
- module-solid
- auto-icons
- i18n
- module-react
- module-solid
- module-svelte
- module-vue
- storage
- wxt
jobs:
sync:
@@ -21,6 +22,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
with:
installArgs: --ignore-scripts
- run: pnpm tsx scripts/sync-releases.ts ${{ inputs.package }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
- name: pnpm test:coverage
run: pnpm test:coverage -- --reporter=default --reporter=hanging-process
- uses: codecov/codecov-action@v4
- uses: codecov/codecov-action@v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
windows-tests:
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
aaronklinker1@gmail.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+22
View File
@@ -32,6 +32,7 @@ Here are some helpful commands:
```sh
# Build WXT package
cd packages/wxt
pnpm build
```
@@ -144,3 +145,24 @@ pnpm dlx taze -r
```
Configuration is in [`taze.config.ts`](./taze.config.ts).
## Install Unreleased Versions
This repo uses https://pkg.pr.new to publish versions of all it's packages for almost every commit. You can install them via:
```sh
npm i https://pkg.pr.new/[package-name]@[ref]
```
Or use one of the shorthands:
```sh
# Install the latest build of `wxt` from a PR:
npm i https://pkg.pr.new/wxt@1283
# Install the latest build of `@wxt-dev/module-react` on the `main` branch
npm i https://pkg.pr.new/@wxt-dev/module-react@main
# Install `@wxt-dev/storage` from a specific commit:
npm i https://pkg.pr.new/@wxt-dev/module-react@426f907
```
+6
View File
@@ -75,6 +75,12 @@ Or see the [installation guide](https://wxt.dev/guide/installation.html) to get
- 📏 Bundle analysis
- ⬇️ Download and bundle remote URL imports
## Sponsors
WXT is a [MIT-licensed](https://github.com/wxt-dev/wxt/blob/main/LICENSE) open source project with its ongoing development made possible entirely by the support of these awesome backers. If you'd like to join them, please consider [sponsoring WXT's development](https://github.com/sponsors/wxt-dev).
<a href="https://github.com/sponsors/wxt-dev"><img alt="WXT Sponsors" src="https://raw.githubusercontent.com/wxt-dev/static/refs/heads/main/sponsorkit/sponsors.svg"></a>
## Contributors
<!-- automd:contributors author="aklinker1" license="MIT" github="wxt-dev/wxt" -->
+15
View File
@@ -0,0 +1,15 @@
# Security Policy
While WXT is in prerelease, only the latest version will recieve security updates. The latest version is:
<img alt="npm version" src="https://img.shields.io/npm/v/wxt?labelColor=black&color=%234fa048">
<!--
## Reporting a Vulnerability
Use this section to tell people how to report a vulnerability.
Tell them where to go, how often they can expect to get an update on a
reported vulnerability, what to expect if the vulnerability is accepted or
declined, etc.
-->
+20 -6
View File
@@ -4,11 +4,8 @@ import useListExtensionDetails, {
ChromeExtension,
} from '../composables/useListExtensionDetails';
// Add extension IDs here. Order doesn't matter, will be sorted by weekly active users
// During the transition from chrome.google.com/webstore to
// chromewebstore.google.com, queue.wxt.dev might return null for your
// extension. If it does, use "<slug>/<id>" instead of just the ID. The slug
// can be retrieved from the URL of the item on chromewebstore.google.com
// Add extension IDs here. Order doesn't matter, will be sorted by a combination of weekly active users and rating.
// Change the commit message or PR title to: "docs: Added "[extension name]" to the homepage"
const chromeExtensionIds = [
'ocfdgncpifmegplaglcnglhioflaimkd', // GitHub: Better Line Counts
'mgmdkjcljneegjfajchedjpdhbadklcf', // Anime Skip Player
@@ -16,7 +13,7 @@ const chromeExtensionIds = [
'elfaihghhjjoknimpccccmkioofjjfkf', // StayFree - Website Blocker & Web Analytics
'okifoaikfmpfcamplcfjkpdnhfodpkil', // Doozy: Ai Made Easy
'lknmjhcajhfbbglglccadlfdjbaiifig', // tl;dv - Record, Transcribe & ChatGPT for Google Meet
'youtube中文配音/oglffgiaiekgeicdgkdlnlkhliajdlja', // Youtube中文配音
'oglffgiaiekgeicdgkdlnlkhliajdlja', // Youtube中文配音
'agjnjboanicjcpenljmaaigopkgdnihi', // PreMiD
'aiakblgmlabokilgljkglggnpflljdgp', // Markdown Sticky Notes
'nomnkbngkijpffepcgbbofhcnafpkiep', // DocVersionRedirector
@@ -50,6 +47,23 @@ const chromeExtensionIds = [
'fcphghnknhkimeagdglkljinmpbagone', // YouTube Auto HD + FPS
'lpomjgbicdemjkgmbnkjncgdebogkhlb', // MultiViewer Companion
'ggiafipgeeaaahnjamgpjcgkdpanhddg', // Sync Watch - Watch videos together on any site
'nmldnjcblcihmegipecakhmnieiofmgl', // Keyword Rank Checker
'gppllamhaciichleihemgilcpledblpn', // YouTube Simple View - Hide distractions & more
'pccbghdfdnnkkbcdcibchpbffdgednkf', // Propbar - Property Data Enhancer
'lfknakglefggmdkjdfhhofkjnnolffkh', // Text Search Pro - Search by case and whole-word match!
'mbenhbocjckkbaojacmaepiameldglij', // Invoice Generator
'phlfhkmdofajnbhgmbmjkbkdgppgoppb', // Monthly Bill Tracker
'macmkmchfoclhpbncclinhjflmdkaoom', // Wandpen - Instantly improve your writing with AI
'lhmgechokhmdekdpgkkemoeecelcaonm', // YouTube Hider - Remove Comments By Keywords, Usernames & Tools
'imgheieooppmahcgniieddodaliodeeg', // QA Compass - Record standardized bug reports easily
'npgghjedpchajflknnbngajkjkdhncdo', // aesthetic Notion, styled
'hmdcmlfkchdmnmnmheododdhjedfccka', // Eye Dropper
'eihpmapodnppeemkhkbhikmggfojdkjd', // Cursorful - Screen Recorder with Auto Zoom
'hjjkgbibknbahijglkffklflidncplkn', // Show IP Live View of Website IPs for Developers
'ilbikcehnpkmldojkcmlldkoelofnbde', // Strong Password Generator
'ocllfkhcdopiafndigclebelbecaiocp', // ZenGram: Mindful Instagram, Your Way
'odffpjnpocjfcaclnenaaaddghkgijdb', // Blync: Preview Links, Selection Search, AI Assistant
'kofbbilhmnkcmibjbioafflgmpkbnmme', // HTML to Markdown - Convert webpages to markdown
];
const { data, err, isLoading } = useListExtensionDetails(chromeExtensionIds);
+13 -2
View File
@@ -13,11 +13,12 @@ import { version as wxtVersion } from '../../packages/wxt/package.json';
import { version as i18nVersion } from '../../packages/i18n/package.json';
import { version as autoIconsVersion } from '../../packages/auto-icons/package.json';
import { version as unocssVersion } from '../../packages/unocss/package.json';
import { version as storageVersion } from '../../packages/storage/package.json';
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 utilities 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 web extensions. 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';
@@ -61,6 +62,16 @@ export default defineConfig({
alt: 'WXT logo',
},
footer: {
message: [
'<a class="light-netlify" href="https://www.netlify.com"> <img src="https://www.netlify.com/v3/img/components/netlify-color-bg.svg" alt="Deploys by Netlify" style="display: inline;" /></a>',
'<a class="dark-netlify" href="https://www.netlify.com"> <img src="https://www.netlify.com/v3/img/components/netlify-color-accent.svg" alt="Deploys by Netlify" style="display: inline;" /></a>',
'Released under the <a href="https://github.com/wxt-dev/wxt/blob/main/LICENSE">MIT License</a>.',
].join('<br/>'),
copyright:
'Copyright © 2023-present <a href="https://github.com/aklinker1">Aaron Klinker</a>',
},
editLink: {
pattern: 'https://github.com/wxt-dev/wxt/edit/main/docs/:path',
},
@@ -87,7 +98,7 @@ export default defineConfig({
),
]),
navItem('Other Packages', [
navItem(`wxt/storage — ${wxtVersion}`, '/storage'),
navItem(`@wxt-dev/storage — ${storageVersion}`, '/storage'),
navItem(`@wxt-dev/auto-icons — ${autoIconsVersion}`, '/auto-icons'),
navItem(`@wxt-dev/i18n — ${i18nVersion}`, '/i18n'),
navItem(`@wxt-dev/unocss — ${unocssVersion}`, '/unocss'),
+25 -56
View File
@@ -1,72 +1,28 @@
/* Colors */
:root {
--wxt-c-green: #53bc4a;
--wxt-c-green-1: #0b8a00;
--wxt-c-green-2: #096600;
--wxt-c-green-3: #096600;
--wxt-c-green-soft: rgba(11, 138, 0, 0.14);
}
.dark {
--wxt-c-green-1: #67d45e;
--wxt-c-green-2: #4fa048;
--wxt-c-green-3: #447e3f;
--wxt-c-green-2: #329929;
--wxt-c-green-3: #21651b;
--wxt-c-green-soft: rgba(103, 212, 94, 0.14);
}
/* https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css */
:root {
--vp-c-brand: var(--wxt-c-green);
--vp-c-brand-1: var(--wxt-c-green-1);
--vp-c-brand-2: var(--wxt-c-green-2);
--vp-c-brand-3: var(--wxt-c-green-3);
--vp-button-brand-bg: var(--wxt-c-green);
--vp-button-brand-hover-bg: var(--wxt-c-green-2);
--vp-button-brand-active-bg: var(--wxt-c-green-3);
--vp-code-link-color: var(--wxt-c-green);
/* --vp-c-text-1: var(--wxt-c-green-1); */
--vp-button-brand-text: var(--vp-c-black);
--vp-button-brand-hover-text: var(--vp-c-black);
--vp-button-brand-active-text: var(--vp-c-black);
--vp-custom-block-tip-border: var(--wxt-c-green);
--vp-custom-block-tip-text: var(--wxt-c-green);
/* --vp-code-block-bg: #222422;
--vp-code-copy-code-bg: #313431;
--vp-code-copy-code-hover-bg: #3c403c; */
--vp-custom-block-tip-bg: var(--vp-code-block-bg);
--vp-custom-block-info-bg: var(--vp-code-block-bg);
--vp-code-color: #476582;
--vp-c-brand-soft: var(--wxt-c-green-soft);
}
.vp-doc a {
color: var(--wxt-c-green);
}
.dark {
--vp-c-bg: #131413;
--vp-c-bg-soft: #1a1b1a;
--vp-c-bg-soft-up: #1f201f;
--vp-c-bg-soft-down: #262926;
--vp-c-bg-soft-mute: #242424;
--vp-c-bg-alt: #171817;
--vp-c-default: #313136;
--vp-c-default-1: #3a3a3c;
--vp-c-default-2: #505053;
--vp-c-default-3: #2c2c30;
--vp-c-default-soft: #252529;
--vp-code-block-bg: #191a19;
--vp-code-copy-code-bg: #212321;
--vp-code-copy-code-hover-bg: #292d29;
--vp-custom-block-info-bg: #191a19;
--vp-code-color: #c9def1;
}
/* Customize Individual Components */
.vp-doc .no-vertical-dividers th,
.vp-doc .no-vertical-dividers td {
@@ -97,3 +53,16 @@ body {
vertical-align: middle;
background-color: var(--vp-c-default-2);
}
.light-netlify {
display: inline;
}
.dark .light-netlify {
display: none;
}
.dark-netlify {
display: none;
}
.dark .dark-netlify {
display: inline;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 132 KiB

+81 -3
View File
@@ -77,6 +77,84 @@ export default defineContentScript({
## WASM
:::warning 🚧 Under construction
These docs will be coming soon!
:::
How a `.wasm` file is loaded varies greatly between packages, but most follow a basic setup: Use a JS API to load and execute the `.wasm` file.
For an extension, that means two things:
1. The `.wasm` file needs to be present in output folder so it can be loaded.
2. You must import the JS API to load and initialize the `.wasm` file, usually provided by the NPM package.
For an example, let's say you have a content script needs to parse TS code into AST. We'll use [`@oxc-parser/wasm`](https://www.npmjs.com/package/@oxc-parser/wasm) to do it!
First, we need to copy the `.wasm` file to the output directory. We'll do it with a [WXT module](/guide/essentials/wxt-modules):
```ts
// modules/oxc-parser-wasm.ts
import { resolve } from 'node:path';
export default defineWxtModule((wxt) => {
wxt.hook('build:publicAssets', (_, assets) => {
assets.push({
absoluteSrc: resolve(
'node_modules/@oxc-parser/wasm/web/oxc_parser_wasm_bg.wasm',
),
relativeDest: 'oxc_parser_wasm_bg.wasm',
});
});
});
```
Run `wxt build`, and you should see the WASM file copied into your `.output/chrome-mv3` folder!
Next, since this is in a content script and we'll be fetching the WASM file over the network to load it, we need to add the file to the `web_accessible_resources`:
```ts
// wxt.config.ts
export default defineConfig({
manifest: {
web_accessible_resources: [
{
// We'll use this matches in the cotent script as well
matches: ['*://*.github.com/*'],
// Use the same path as `relativeDest` from the WXT module
resources: ['/oxc_parser_wasm_bg.wasm'],
},
],
},
});
```
And finally, we need to load and initialize the `.wasm` file inside the content script to use it:
```ts
// entrypoints/content.ts
import initWasm, { parseSync } from '@oxc-parser/wasm';
export default defineContentScript({
matches: '*://*.github.com/*',
async main(ctx) {
if (!location.pathname.endsWith('.ts')) return;
// Get text from GitHub
const code = document.getElementById(
'read-only-cursor-text-area',
)?.textContent;
if (!code) return;
const sourceFilename = document.getElementById('file-name-id')?.textContent;
if (!sourceFilename) return;
// Load the WASM file:
await initWasm({
module_or_path: browser.runtime.getURL('/oxc_parser_wasm_bg.wasm'),
});
// Once loaded, we can use `parseSync`!
const ast = parseSync(code, { sourceFilename });
console.log(ast);
},
});
```
This code is taken directly from `@oxc-parser/wasm` docs with one exception: We manually pass in a file path. In a standard NodeJS or web project, the default path works just fine so you don't have to pass anything in. However, extensions are different. You should always explicitly pass in the full URL to the WASM file in your output directory, which is what `browser.runtime.getURL` returns.
Run your extension, and you should see OXC parse the TS file!
@@ -41,6 +41,8 @@ export default defineRunnerConfig({
});
```
By default, WXT will try to automatically discover where Chrome/Firefox are installed. However, if you have chrome installed in a non-standard location, you need to set it manually as shown above.
### Persist Data
By default, to keep from modifying your browser's existing profiles, `web-ext` creates a brand new profile every time you run the `dev` script.
@@ -62,7 +64,8 @@ import { resolve } from 'node:path';
export default defineRunnerConfig({
// On Windows, the path must be absolute
chromiumArgs: [`--user-data-dir="${resolve(".wxt/chrome-data")}"`
chromiumProfile: resolve('.wxt/chrome-data'),
keepProfileChanges: true,
});
```
+17 -20
View File
@@ -1,10 +1,17 @@
# Manifest
In WXT, there is no `manifest.json` file in your source code. Instead, WXT generates it during the build process based off files in your project.
In WXT, there is no `manifest.json` file in your source code. Instead, WXT generates the manifest from multiple sources:
## Manifest Config
- Global options [defined in your `wxt.config.ts` file](#global-options)
- Entrypoint-specific options [defined in your entrypoints](/guide/essentials/entrypoints#defining-manifest-options)
- [WXT Modules](/guide/essentials/wxt-modules) added to your project can modify your manifest
- [Hooks](/guide/essentials/config/hooks) defined in your project can modify your manifest
To manually add a property to the `manifest.json` output during builds, use the `manifest` config inside `wxt.config.ts`:
Your extension's `manifest.json` will be output to `.output/{target}/manifest.json` when running `wxt build`.
## Global Options
To add a property to your manifest, use the `manifest` config inside your `wxt.config.ts`:
```ts
export default defineConfig({
@@ -130,7 +137,7 @@ public/
└─ icon-128.png
```
Specifically, if an icon must match one of these regex to be discovered:
Specifically, an icon must match one of these regex to be discovered:
<<< @/../packages/wxt/src/core/utils/manifest.ts#snippet
@@ -156,6 +163,11 @@ Alternatively, you can use [`@wxt-dev/auto-icons`](https://www.npmjs.com/package
> [Chrome docs](https://developer.chrome.com/docs/extensions/reference/permissions/)
Most of the time, you need to manually add permissions to your manifest. Only in a few specific situations are permissions added automatically:
- During development: the `tabs` and `scripting` permissions will be added to enable hot reloading.
- When a `sidepanel` entrypoint is present: The `sidepanel` permission is added.
```ts
export default defineConfig({
manifest: {
@@ -211,22 +223,7 @@ By default, whenever an `action` is generated, WXT falls back to `browser_action
### Action With Popup
To generate a manifest where a UI appears after clicking the icon, just create a [Popup entrypoint](/guide/essentials/entrypoints#popup).
```ts
export default defineConfig({
hooks: {
build: {
manifestGenerated(manifest) {
// Update the manifest variable by reference
manifest.name = 'Overriden name';
},
},
},
});
```
If you want to use a `page_action` for MV2, add the following meta tag to the HTML document's head:
To generate a manifest where a UI appears after clicking the icon, just create a [Popup entrypoint](/guide/essentials/entrypoints#popup). If you want to use a `page_action` for MV2, add the following meta tag to the HTML document's head:
```html
<meta name="manifest.type" content="page_action" />
+77 -22
View File
@@ -4,12 +4,14 @@ outline: deep
# Content Scripts
> To create a content script, see [Entrypoint Types](/guide/essentials/entrypoints#content-scripts).
## Context
The first argument to a content script's `main` function is it's "context".
```ts
// entrypoints/content.ts
// entrypoints/example.content.ts
export default defineContentScript({
main(ctx) {},
});
@@ -62,7 +64,7 @@ In regular web extensions, CSS for content scripts is usually a separate CSS fil
In WXT, to add CSS to a content script, simply import the CSS file into your JS entrypoint, and WXT will automatically add the bundled CSS output to the `css` array.
```ts
// entrypoints/content/index.ts
// entrypoints/example.content/index.ts
import './style.css';
export default defineContentScript({
@@ -78,16 +80,16 @@ To create a standalone content script that only includes a CSS file:
// wxt.config.ts
export default defineConfig({
hooks: {
"build:manifestGenerated": (wxt, manifest) => {
'build:manifestGenerated': (wxt, manifest) => {
manifest.content_scripts ??= [];
manifest.content_scripts.push({
// Build extension once to see where your CSS get's written to
css: ["content-scripts/example.css"],
matches: ["*://*/*"]
)
}
}
})
css: ['content-scripts/example.css'],
matches: ['*://*/*'],
});
},
},
});
```
## UI
@@ -198,6 +200,7 @@ export default defineContentScript({
```ts [Svelte]
// entrypoints/example-ui.content/index.ts
import App from './App.svelte';
import { mount, unmount } from 'svelte';
export default defineContentScript({
matches: ['<all_urls>'],
@@ -208,14 +211,13 @@ export default defineContentScript({
anchor: 'body',
onMount: (container) => {
// Create the Svelte app inside the UI container
const app = new App({
mount(App, {
target: container,
});
return app;
},
onRemove: (app) => {
// Destroy the app when the UI is removed
app.$destroy();
unmount(app);
},
});
@@ -239,6 +241,7 @@ export default defineContentScript({
onMount: (container) => {
// Render your app to the UI container
const unmount = render(() => <div>...</div>, container);
return unmount;
},
onRemove: (unmount) => {
// Unmount the app when the UI is removed
@@ -378,6 +381,7 @@ export default defineContentScript({
// 1. Import the style
import './style.css';
import App from './App.svelte';
import { mount, unmount } from 'svelte';
export default defineContentScript({
matches: ['<all_urls>'],
@@ -392,14 +396,13 @@ export default defineContentScript({
anchor: 'body',
onMount: (container) => {
// Create the Svelte app inside the UI container
const app = new App({
mount(App, {
target: container,
});
return app;
},
onRemove: (app) => {
onRemove: () => {
// Destroy the app when the UI is removed
app?.$destroy();
unmount(app);
},
});
@@ -547,16 +550,35 @@ To use `injectScript`, we need two entrypoints, one content script and one unlis
```ts
// entrypoints/example-main-world.ts
export default defineUnlistedScript(() => {
console.log('Hello from the main world!');
console.log('Hello from the main world');
});
```
```ts
// entrypoints/example.content.ts
export default defineContentScript(async () => {
await injectScript('/example-main-world.js', {
keepInDom: true,
});
export default defineContentScript({
matches: ['*://*/*'],
async main() {
console.log('Injecting script...');
await injectScript('/example-main-world.js', {
keepInDom: true,
});
console.log('Done!');
},
});
```
```json
export default defineConfig({
manifest: {
// ...
web_accessible_resources: [
{
resources: ["example-main-world.js"],
matches: ["*://*/*"],
}
]
}
});
```
@@ -570,6 +592,39 @@ For MV3, `injectScript` is synchronous and the injected script will be evaluated
However for MV2, `injectScript` has to `fetch` the script's text content and create an inline `<script>` block. This means for MV2, your script is injected asynchronously and it will not be evaluated at the same time as your content script's `run_at`.
:::
## Mounting UI to dynamic element
In many cases, you may need to mount a UI to a DOM element that does not exist at the time the web page is initially loaded. To handle this, use the `autoMount` API to automatically mount the UI when the target element appears dynamically and unmount it when the element disappears. In WXT, the `anchor` option is used to target the element, enabling automatic mounting and unmounting based on its appearance and removal.
```ts
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
// It observes the anchor
anchor: '#your-target-dynamic-element',
onMount: (container) => {
// Append children to the container
const app = document.createElement('p');
app.textContent = '...';
container.append(app);
},
});
// Call autoMount to observe anchor element for add/remove.
ui.autoMount();
},
});
```
:::tip
When the `ui.remove` is called, `autoMount` also stops.
:::
See the [API Reference](/api/reference/wxt/client/interfaces/ContentScriptUi.html#automount) for the complete list of options.
## Dealing with SPAs
It is difficult to write content scripts for SPAs (single page applications) and websites using HTML5 history mode for navigation because content scripts are only ran on full page reloads. SPAs and websites that take advantage of HTML5 history mode **_do not perform a full reload when changing paths_**, and thus your content script isn't going to be ran when you expect it to be.
@@ -602,7 +657,7 @@ export default defineContentScript({
matches: ['*://*.youtube.com/*'],
main(ctx) {
ctx.addEventListener(window, 'wxt:locationchange', ({ newUrl }) => {
if (watchPattern.matches(newUrl)) mainWatch(ctx);
if (watchPattern.includes(newUrl)) mainWatch(ctx);
});
},
});
+28 -7
View File
@@ -28,7 +28,7 @@ For web extensions, there are two types of entrypoints:
- **Listed**: Referenced in the `manifest.json`
- **Unlisted**: Not referenced in the `manifest.json`
Throughout the rest of WXT's documentation, listed files are referred to by name. For example:
Throughout the rest of WXT's documentation, listed entrypoints are referred to by name. For example:
- Popup
- Options
@@ -42,7 +42,7 @@ Some examples of "unlisted" entrypoints:
- JS files injected by content scripts into the page's main world
:::tip
Regardless of whether a entrypoint is listed or unlisted, it will still be bundled into your extension and be available at runtime.
Regardless of whether an entrypoint is listed or unlisted, it will still be bundled into your extension and be available at runtime.
:::
## Adding Entrypoints
@@ -72,7 +72,7 @@ Refer to the [Entrypoint Types](#entrypoint-types) section for the full list of
## Defining Manifest Options
Most listed entrypoints have options that need to be added to the `manifest.json`. With WXT however, instead of defining the options in a separate file, _you define these options inside the entrypoint file itself_.
Most listed entrypoints have options that need to be added to the `manifest.json`. However with WXT, instead of defining the options in a separate file, _you define these options inside the entrypoint file itself_.
For example, here's how to define `matches` for content scripts:
@@ -86,6 +86,17 @@ export default defineContentScript({
});
```
For HTML entrypoints, options are configured as `<meta>` tags. For example, to use a `page_action` for your MV2 popup:
```html
<!doctype html>
<html lang="en">
<head>
<meta name="manifest.type" content="page_action" />
</head>
</html>
```
> Refer to the [Entrypoint Types](#entrypoint-types) sections for a list of options configurable inside each entrypoint, and how to define them.
When building your extension, WXT will look at the options defined in your entrypoints, and generate the manifest accordingly.
@@ -304,14 +315,14 @@ Follow the [Devtools Example](https://github.com/wxt-dev/examples/tree/main/exam
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewportYou are responsible for loading/running these scripts where needed. If necessary, don't forget to add the script and/or any related stylesheets to web_accessible_resources."
content="width=device-width, initial-scale=1.0"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Options Title</title>
<!-- Customize the manifest options -->
<meta name="manifest.open_in_tab" content="true|false" />
<meta name="manifest.chrome_style" 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', ...]" />
@@ -339,7 +350,11 @@ Follow the [Devtools Example](https://github.com/wxt-dev/examples/tree/main/exam
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Set the `action.default_title` in the manifest -->
<title>Default Popup Title</title>
<!-- Customize the manifest options -->
<meta
name="manifest.default_icon"
content="{
@@ -350,6 +365,7 @@ Follow the [Devtools Example](https://github.com/wxt-dev/examples/tree/main/exam
/>
<meta name="manifest.type" content="page_action|browser_action" />
<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', ...]" />
@@ -384,6 +400,7 @@ Firefox does not support sandboxed pages.
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
@@ -416,6 +433,8 @@ In Chrome, side panels use the `side_panel` API, while Firefox uses the `sidebar
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Default Side Panel Title</title>
<!-- Customize the manifest options -->
<meta
name="manifest.default_icon"
content="{
@@ -426,6 +445,7 @@ In Chrome, side panels use the `side_panel` API, while Firefox uses the `sidebar
/>
<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', ...]" />
@@ -475,6 +495,7 @@ body {
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
+1 -1
View File
@@ -20,7 +20,7 @@ In your background script, set `type: "module"`:
```ts
export default defineBackground({
type: 'module', // !code ++
type: 'module', // [!code ++]
main() {
// ...
},
+4
View File
@@ -81,6 +81,10 @@ However, it is recommended you stick with the vanilla API (or a package based on
- Translations are not bundled multiple times, keeping your extension small
- Zero configuration
However, there is one major downside to the vanilla API and any packages built on top of it:
- Language cannot be changed without changing your browser/system language
Here are some examples of how to setup a third party i18n library:
- [vue-i18n](https://github.com/wxt-dev/wxt-examples/tree/main/examples/vue-i18n)
+18 -18
View File
@@ -59,24 +59,24 @@ After enabling it, your project structure should look like this:
<!-- prettier-ignore -->
```html
📂 {rootDir}/
📁 .output/
📁 .wxt/
📂 src/
📁 assets/
📁 components/
📁 composables/
📁 entrypoints/
📁 hooks/
📁 modules/
📁 public/
📁 utils/
📄 app.config.ts
📄 .env
📄 .env.publish
📄 package.json
📄 tsconfig.json
📄 web-ext.config.ts
📄 wxt.config.ts
📁 .output/
📁 .wxt/
📂 src/
📁 assets/
📁 components/
📁 composables/
📁 entrypoints/
📁 hooks/
📁 modules/
📁 public/
📁 utils/
📄 app.config.ts
📄 .env
📄 .env.publish
📄 package.json
📄 tsconfig.json
📄 web-ext.config.ts
📄 wxt.config.ts
```
## Customizing Other Directories
+1 -1
View File
@@ -129,7 +129,7 @@ wxt zip
Firefox requires you to upload a ZIP of your source code. This allows them to rebuild your extension and review the code in a readable way. More details can be found in [Firefox's docs](https://extensionworkshop.com/documentation/publish/source-code-submission/).
When running `wxt zip -b firefox`, WXT will zip both your extension and sources. Certain files (such as config files, hidden files, and tests) are automatically excluded from your sources. However, it's important to manually check the ZIP to ensure it only contains the files necessary to rebuild your extension.
When running `wxt zip -b firefox`, WXT will zip both your extension and sources. Certain files (such as config files, hidden files, tests, and excluded entrypoints) are automatically excluded from your sources. However, it's important to manually check the ZIP to ensure it only contains the files necessary to rebuild your extension.
To customize which files are zipped, add the `zip` option to your config file.
+8 -5
View File
@@ -12,15 +12,18 @@ When using `browser.scripting.executeScript`, you can execute content scripts or
// entrypoints/background.ts
const res = await browser.scripting.executeScript({
target: { tabId },
files: ['injected.js'],
files: ['content-scripts/example.js'],
});
console.log(res); // "Hello John!"
```
```ts
// entrypoints/injected.js
export default defineUnlistedScript(() => {
console.log('Script was injected!');
return 'Hello John!';
// entrypoints/example.content.ts
export default defineContentScript({
registration: 'runtime',
main(ctx) {
console.log('Script was executed!');
return 'Hello John!';
},
});
```
@@ -75,3 +75,5 @@ Here are some examples:
</body>
</html>
```
Alternatively, you can use the [`filterEntrypoints` config](/api/reference/wxt/interfaces/InlineConfig#filterentrypoints) to list all the entrypoints you want to build.
+26 -1
View File
@@ -75,7 +75,7 @@ import { defineWxtModule } from 'wxt/modules';
export default defineWxtModule({
setup(wxt) {
wxt.hook('ready', () => {
wxt.hook('config:resolved', () => {
wxt.config.outDir = 'dist';
});
},
@@ -167,6 +167,31 @@ This file could then be loaded at runtime:
const res = await fetch(browser.runtime.getURL('/some-text.txt'));
```
#### Add custom entrypoints
Once the existing files under the `entrypoints/` directory have been discovered, the `entrypoints:found` hook can be used to add custom entrypoints.
:::info
The `entrypoints:found` hook is triggered before validation is carried out on the list of entrypoints. Thus, any custom entrypoints will still be checked for duplicate names and logged during debugging.
:::
```ts
import { defineWxtModule } from 'wxt/modules';
export default defineWxtModule({
setup(wxt) {
wxt.hook('entrypoints:found', (_, entrypointInfos) => {
// Add your new entrypoint
entrypointInfos.push({
name: 'my-custom-script',
inputPath: 'path/to/custom-script.js',
type: 'content-script',
});
});
},
});
```
#### Generate runtime module
Create a file in `.wxt`, add an alias to import it, and add auto-imports for exported variables.
+1 -1
View File
@@ -9,7 +9,7 @@ Check out the [comparison](/guide/resources/compare) to see how WXT compares to
## Prerequisites
These docs assume you have a basic knowledge of how Chrome extensions are structured and how you access the extension APIs.
These docs assume you have a basic knowledge of how web extensions are structured and how you access the extension APIs.
:::warning New to extension development?
If you have never written an extension before, follow Chrome's [Hello World tutorial](https://developer.chrome.com/docs/extensions/get-started/tutorial/hello-world) to first **_create an extension without WXT_**, then come back here.
+146 -3
View File
@@ -8,9 +8,7 @@ Commonly asked questions about how to use WXT or why it behaves the way it does.
[[toc]]
---
### Why are content scripts not showing up in the manifest?
## Why aren't content scripts added to the manifest?
During development, WXT registers content scripts dynamically so they can be reloaded individually when a file is saved without reloading your entire extension.
@@ -19,3 +17,148 @@ To list the content scripts registered during development, open the service work
```js
await chrome.scripting.getRegisteredContentScripts();
```
## How do I disable opening the browser automatically during development?
See https://wxt.dev/guide/essentials/config/browser-startup.html#disable-opening-browser
## How do I stay logged into a website during development?
See https://wxt.dev/guide/essentials/config/browser-startup.html#persist-data
## My component library doesn't work in content scripts!
This is usually caused by one of two things (or both) when using `createShadowRootUi`:
1. Styles are added outside the `ShadowRoot`
:::details
Some component libraries manually add CSS to the page by adding a `<style>` or `<link>` element. They place this element in the document's `<head>` by default. This causes your styles to be placed outside the `ShadowRoot` and it's isolation blocks the styles from being applied to your UI.
When a library does this, **you need to tell the library where to put its styles**. Here's the documentation for a few popular component libraries:
- Ant Design: [`StyleProvider`](https://ant.design/docs/react/compatible-style#shadow-dom-usage)
- Mantine: [`MantineProvider#getRootElement` and `MantineProvider#cssVariablesSelector`](https://mantine.dev/theming/mantine-provider/)
> If your library isn't listed above, try searching it's docs/issues for "shadow root", "shadow dom", or "css container". Not all libraries support shadow DOMs, you may have to open an issue to request this feature.
Here's an example of configuring Antd's styles:
```tsx
import { StyleProvider } from '@ant-design/cssinjs';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
const ui = await create`ShadowRoot`Ui(ctx, {
// ...
onMount: (container, shadow) => {
const cssContainer = shadow.querySelector('head')!;
const root = ReactDOM.createRoot(container);
root.render(
<StyleProvider container={cssContainer}>
<App />
</StyleProvider>,
);
return root;
},
});
```
:::
2. UI elements are added outside the `ShadowRoot`
::::::details
This is mostly caused by `Teleport` or `Portal` components that render an element somewhere else in the DOM, usually in the document's `<body>`. This is usually done for dialogs or popover components. This renders the element is outside the `ShadowRoot`, so styles are not applied to it.
To fix this, **you need to both provide a target to your app AND pass the target to the `Teleport`/`Portal`**.
First, store the reference to the `ShadowRoot`'s `<body>` element (not the document's `<body>`):
:::code-group
```ts [Vue]
import { createApp } from 'vue';
import App from './App.vue';
const ui = await create`ShadowRoot`Ui(ctx, {
// ...
onMount: (container, shadow) => {
const teleportTarget = shadow.querySelector('body')!;
const app = createApp(App)
.provide('TeleportTarget', teleportTarget)
.mount(container);
return app;
},
});
ui.mount();
```
```tsx [React]
// hooks/PortalTargetContext.ts
import { createContext } from 'react';
export const PortalTargetContext = createContext<HTMLElement>();
// entrypoints/example.content.ts
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import PortalTargetContext from '~/hooks/PortalTargetContext';
const ui = await create`ShadowRoot`Ui(ctx, {
// ...
onMount: (container, shadow) => {
const portalTarget = shadow.querySelector('body')!;
const root = ReactDOM.createRoot(container);
root.render(
<PortalTargetContext.Provider value={portalTarget}>
<App />
</PortalTargetContext.Provider>,
);
return root;
},
});
ui.mount();
```
:::
Then use the reference when teleporting/portaling part of your UI to a different place in the DOM:
:::code-group
```vue [Vue]
<script lang="ts" setup>
import { Teleport } from 'vue';
const teleportTarget = inject('TeleportTarget');
</script>
<template>
<div>
<Teleport :to="teleportTarget">
<dialog>My dialog</dialog>
</Teleport>
</div>
</template>
```
```tsx [React]
import { useContext } from 'react';
import { createPortal } from 'react-dom';
import PortalTargetContext from '~/hooks/PortalTargetContext';
const MyComponent = () => {
const portalTarget = useContext(PortalTargetContext);
return <div>{createPortal(<dialog>My dialog</dialog>, portalTarget)}</div>;
};
```
:::
::::::
Both issues have the same cause: the library puts something outside the `ShadowRoot`, and the `ShadowRoot`'s isolation prevents CSS from being applied to your UI.
Both issues have the same fix: tell the library to put elements inside the `ShadowRoot`, not outside it. See the details above for more information and example fixes for each problem.
-21
View File
@@ -109,27 +109,6 @@ You have two options:
```
2. Rename your `modules` directory to something else.
## v0.18.0 &rarr; v0.18.5
> When this version was released, it was not considered a breaking change... but it should have been.
### New `modules/` Directory
WXT now recognizes the `modules/` directory as a folder containing [WXT modules](/guide/essentials/wxt-modules).
If you already have `<srcDir>/modules` or `<srcDir>/Modules` directory, `wxt prepare` and other commands will fail.
You have two options:
1. [Recommended] Keep your files where they are and tell WXT to look in a different folder:
```ts
// wxt.config.ts
export default defineConfig({
modulesDir: 'wxt-modules', // defaults to "modules"
});
```
2. Rename your `modules` directory to something else.
## v0.17.0 &rarr; v0.18.0
### Automatic MV3 `host_permissions` to MV2 `permissions`
+9 -3
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 development faster than ever before.
tagline: An open source tool that makes web extension development faster than ever before.
image:
src: /hero-logo.svg
alt: WXT
@@ -73,9 +73,15 @@ features:
linkText: Read docs
---
## Sponsors
WXT is a [MIT-licensed](https://github.com/wxt-dev/wxt/blob/main/LICENSE) open source project with its ongoing development made possible entirely by the support of these awesome backers. If you'd like to join them, please consider [sponsoring WXT's development](https://github.com/sponsors/wxt-dev).
<a href="https://github.com/sponsors/wxt-dev"><img alt="WXT Sponsors" src="https://raw.githubusercontent.com/wxt-dev/static/refs/heads/main/sponsorkit/sponsors-wide.svg"></a>
## Put <span style="color: var(--vp-c-brand-1)">Developer Experience</span> First
WXT simplifies the chrome extension development process by providing tools for zipping and publishing, the best-in-class dev mode, an opinionated project structure, and more. Iterate faster, develop features not build scripts, and use everything the JS ecosystem has to offer.
WXT simplifies the web extension development process by providing tools for zipping and publishing, the best-in-class dev mode, an opinionated project structure, and more. Iterate faster, develop features not build scripts, and use everything the JS ecosystem has to offer.
<div style="margin: auto; width: 100%; max-width: 900px; text-align: center">
<video src="https://github.com/wxt-dev/wxt/assets/10101283/4d678939-1bdb-495c-9c36-3aa281d84c94" controls></video>
@@ -87,7 +93,7 @@ WXT simplifies the chrome extension development process by providing tools for z
## Who's Using WXT?
Battle tested and ready for production. Explore chrome extensions made with WXT.
Battle tested and ready for production. Explore web extensions made with WXT.
<ClientOnly>
<UsingWxtSection />
+69 -22
View File
@@ -6,23 +6,47 @@ outline: deep
[Changelog](https://github.com/wxt-dev/wxt/blob/main/packages/wxt/CHANGELOG.md)
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:
A simplified wrapper around the extension storage APIs.
## Installation
### With WXT
This module is built-in to WXT, so you don't need to install anything.
```ts
import { storage } from 'wxt/storage';
```
> [!IMPORTANT]
> To use the `wxt/storage` API, the `"storage"` permission must be added to the manifest:
>
> ```ts
> // wxt.config.ts
> export default defineConfig({
> manifest: {
> permissions: ['storage'],
> },
> });
> ```
If you use auto-imports, `storage` is auto-imported for you, so you don't even need to import it!
### Without WXT
Install the NPM package:
```sh
npm i @wxt-dev/storage
pnpm add @wxt-dev/storage
yarn add @wxt-dev/storage
bun add @wxt-dev/storage
```
```ts
import { storage } from '@wxt-dev/storage';
```
## Storage Permission
To use the `wxt/storage` API, the `"storage"` permission must be added to the manifest:
```ts
// wxt.config.ts
export default defineConfig({
manifest: {
permissions: ['storage'],
},
});
```
## Basic Usage
@@ -51,11 +75,11 @@ await storage.watch<number>(
await storage.getMeta<{ v: number }>('local:installDate');
```
For a full list of methods available, see the [API reference](/api/reference/wxt/storage/interfaces/WxtStorage).
For a full list of methods available, see the [API reference](/api/reference/@wxt-dev/storage/interfaces/WxtStorage).
## Watchers
To listen for storage changes, use the `storage.watch` function. It lets you setup a listener for a single key:
To listen for storage changes, use the `storage.watch` function. It lets you set up a listener for a single key:
```ts
const unwatch = storage.watch<number>('local:counter', (newCount, oldCount) => {
@@ -100,7 +124,7 @@ You can remove all metadata associated with a key, or just specific properties:
// Remove all properties
await storage.removeMeta('local:preference');
// Remove one property
// Remove only the "lastModified" property
await storage.removeMeta('local:preference', 'lastModified');
// Remove multiple properties
@@ -134,7 +158,7 @@ const unwatch = showChangelogOnUpdate.watch((newValue) => {
});
```
For a full list of properties and methods available, see the [API reference](/api/reference/wxt/storage/interfaces/WxtStorageItem).
For a full list of properties and methods available, see the [API reference](/api/reference/@wxt-dev/storage/interfaces/WxtStorageItem).
### Versioning
@@ -225,13 +249,13 @@ export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV3[]>( // [!code
Internally, this uses a metadata property called `v` to track the value's current version.
:::
In this case, we thought that the ignored website list might change in the future, and were able to setup a versioned storage item from the start.
In this case, we thought that the ignored website list might change in the future, and were able to set up a versioned storage item from the start.
Realistically, you won't know a item needs versioned until you need to change it's schema. Thankfully, it's simple to add versioning to an unversioned storage item.
Realistically, you won't know an item needs versioning until you need to change its schema. Thankfully, it's simple to add versioning to an unversioned storage item.
When a previous version isn't found, WXT assumes the version was `1`. That means you just need to set `version: 2` and add a migration for `2`, and it will just work!
Lets look at the same ignored websites example from before, but start with an unversioned item this time:
Let's look at the same ignored websites example from before, but start with an unversioned item this time:
:::code-group
@@ -275,13 +299,13 @@ 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.
As soon as `storage.defineItem` is called, WXT checks if migrations need to be run, 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.
### Default Values
With `storage.defineItem`, there are multiple ways of defining default values:
1. `fallback` - Return this value from `getValue` instead of `null` if the value is missing.
1. **`fallback`** - Return this value from `getValue` instead of `null` if the value is missing.
This option is great for providing default values for settings:
@@ -294,7 +318,7 @@ With `storage.defineItem`, there are multiple ways of defining default values:
});
```
2. `init` - Initialize and save a value in storage if it is not already saved.
2. **`init`** - Initialize and save a value in storage if it is not already saved.
This is great for values that need to be initialized or set once:
@@ -308,3 +332,26 @@ With `storage.defineItem`, there are multiple ways of defining default values:
```
The value is initialized in storage immediately.
## Bulk Operations
When getting or setting multiple values in storage, you can perform bulk operations to improve performance by reducing the number of individual storage calls. The `storage` API provides several methods for performing bulk operations:
- **`getItems`** - Get multiple values at once.
- **`getMetas`** - Get metadata for multiple items at once.
- **`setItems`** - Set multiple values at once.
- **`setMetas`** - Set metadata for multiple items at once.
- **`removeItems`** - Remove multiple values (and optionally metadata) at once.
All these APIs support both string keys and defined storage items:
```ts
const userId = storage.defineItem('local:userId');
await storage.setItems([
{ key: 'local:installDate', value: Date.now() },
{ item: userId, value: generateUserId() },
]);
```
Refer to the [API Reference](/api/reference/@wxt-dev/storage/interfaces/WxtStorage) for types and examples of how to use all the bulk APIs.
+1 -1
View File
@@ -20,7 +20,7 @@ Set Framerate 50
# Terminal theme with WXT brand colors (which was taken from the website)
# Based on the standard charmbracelet/vhs theme:
# https://github.com/charmbracelet/vhs/blob/88e634f4a10bbe305b6aea9a12b4d8dc3dd7f31c/style.go#L7-L28
Set Theme {"background": "#1a1b1a", "foreground": "#dddddd", "black": "#282a2e", "brightBlack": "#4d4d4d", "red": "#D74E6F", "brightRed": "#FE5F86", "green": "#67d45e", "brightGreen": "#67d45e", "yellow": "#D3E561", "brightYellow": "#EBFF71", "blue": "#8056FF", "brightBlue": "#9B79FF", "magenta": "#ED61D7", "brightMagenta": "#FF7AEA", "cyan": "#04D7D7", "brightCyan": "#00FEFE", "white": "#bfbfbf", "brightWhite": "#e6e6e6", "indigo": "#5B56E0"}
Set Theme {"background": "#161618", "foreground": "#dddddd", "black": "#282a2e", "brightBlack": "#4d4d4d", "red": "#D74E6F", "brightRed": "#FE5F86", "green": "#67d45e", "brightGreen": "#67d45e", "yellow": "#D3E561", "brightYellow": "#EBFF71", "blue": "#8056FF", "brightBlue": "#9B79FF", "magenta": "#ED61D7", "brightMagenta": "#FF7AEA", "cyan": "#04D7D7", "brightCyan": "#00FEFE", "white": "#bfbfbf", "brightWhite": "#e6e6e6", "indigo": "#5B56E0"}
Set FontSize 32
# Terminal settings
+9 -2
View File
@@ -2,11 +2,18 @@
"$schema": "https://typedoc.org/schema.json",
"entryPointStrategy": "packages",
"entryPoints": ["../packages/wxt"],
"plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme"],
"plugin": [
"typedoc-plugin-markdown",
"typedoc-vitepress-theme",
"typedoc-plugin-frontmatter"
],
"out": "./api/reference",
"githubPages": false,
"excludePrivate": true,
"excludeProtected": true,
"excludeInternal": true,
"readme": "none"
"readme": "none",
"frontmatterGlobals": {
"editLink": false
}
}
+7 -6
View File
@@ -4,7 +4,7 @@
"engines": {
"node": ">=18.20.3"
},
"packageManager": "pnpm@9.12.0",
"packageManager": "pnpm@9.13.0",
"scripts": {
"check": "check && pnpm -r --sequential run check",
"test": "pnpm -r --sequential run test run",
@@ -20,24 +20,25 @@
"@aklinker1/buildc": "^1.1.4",
"@aklinker1/check": "^1.4.5",
"@types/fs-extra": "^11.0.4",
"@vitest/coverage-v8": "^2.1.2",
"@vitest/coverage-v8": "^2.1.4",
"changelogen": "^0.5.7",
"consola": "^3.2.3",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
"lint-staged": "^15.2.10",
"markdown-it-footnote": "^4.0.0",
"nano-spawn": "^0.1.0",
"nano-spawn": "^0.2.0",
"prettier": "^3.3.3",
"simple-git-hooks": "^2.11.1",
"tsx": "4.15.7",
"typedoc": "^0.25.4",
"typedoc-plugin-frontmatter": "^1.1.0",
"typedoc-plugin-markdown": "4.0.0-next.23",
"typedoc-vitepress-theme": "1.0.0-next.3",
"typescript": "^5.6.2",
"vitepress": "^1.3.4",
"typescript": "^5.6.3",
"vitepress": "^1.5.0",
"vitest-mock-extended": "^2.0.2",
"vue": "^3.5.11",
"vue": "^3.5.12",
"wxt": "workspace:*"
},
"simple-git-hooks": {
+3 -3
View File
@@ -46,9 +46,9 @@
},
"devDependencies": {
"@aklinker1/check": "^1.4.5",
"oxlint": "^0.9.9",
"publint": "^0.2.11",
"typescript": "^5.6.2",
"oxlint": "^0.11.1",
"publint": "^0.2.12",
"typescript": "^5.6.3",
"unbuild": "^2.0.0",
"wxt": "workspace:*"
},
+1
View File
@@ -354,6 +354,7 @@ You'll need to configure it the extension so it knows where your localization fi
# You can check available language ids here: https://code.visualstudio.com/docs/languages/identifiers
languageIds:
- typescript
- typescriptreact
# Look for t("...")
usageMatchRegex:
+7 -7
View File
@@ -26,7 +26,7 @@
},
"dependencies": {
"chokidar": "^3.6.0",
"confbox": "^0.1.7",
"confbox": "^0.1.8",
"fast-glob": "^3.3.2"
},
"peerDependencies": {
@@ -39,13 +39,13 @@
},
"devDependencies": {
"@aklinker1/check": "^1.4.5",
"@types/chrome": "^0.0.268",
"@types/node": "^20.16.10",
"oxlint": "^0.9.9",
"publint": "^0.2.11",
"typescript": "^5.6.2",
"@types/chrome": "^0.0.280",
"@types/node": "^20.17.6",
"oxlint": "^0.11.1",
"publint": "^0.2.12",
"typescript": "^5.6.3",
"unbuild": "^2.0.0",
"vitest": "^2.0.0",
"vitest": "^2.1.4",
"vitest-plugin-random-seed": "^1.1.0",
"wxt": "workspace:*"
},
+30
View File
@@ -1,5 +1,35 @@
# Changelog
## v1.1.3
[compare changes](https://github.com/wxt-dev/wxt/compare/module-react-v1.1.2...module-react-v1.1.3)
### 🩹 Fixes
- Use react 19 in `@wxt-dev/module-react` and `templates/react` ([#1285](https://github.com/wxt-dev/wxt/pull/1285))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
## v1.1.2
[compare changes](https://github.com/wxt-dev/wxt/compare/module-react-v1.1.1...module-react-v1.1.2)
### 🩹 Fixes
- Use `config:resolved` hook to update config instead of `ready` ([#1178](https://github.com/wxt-dev/wxt/pull/1178))
### 🏡 Chore
- Add `oxlint` for linting ([#947](https://github.com/wxt-dev/wxt/pull/947))
- Upgrade all non-major dependencies ([#1040](https://github.com/wxt-dev/wxt/pull/1040))
- **deps:** Upgrade all non-major dependencies ([#1164](https://github.com/wxt-dev/wxt/pull/1164))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
## v1.1.1
[compare changes](https://github.com/wxt-dev/wxt/compare/module-react-v1.1.0...module-react-v1.1.1)
+1 -1
View File
@@ -15,7 +15,7 @@ export default defineWxtModule<ReactModuleOptions>({
addImportPreset(wxt, 'react');
// Enable auto-imports for JSX files
wxt.hooks.hook('ready', (wxt) => {
wxt.hook('config:resolved', (wxt) => {
if (wxt.config.imports === false) return;
wxt.config.imports.dirsScanOptions ??= {};
+9 -9
View File
@@ -17,7 +17,7 @@
"email": "aaronklinker1+wxt@gmail.com"
},
"license": "MIT",
"version": "1.1.1",
"version": "1.1.3",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
@@ -44,19 +44,19 @@
"prepare": "buildc --deps-only -- wxt prepare"
},
"peerDependencies": {
"wxt": ">=0.18.6"
"wxt": ">=0.19.16"
},
"dependencies": {
"@vitejs/plugin-react": "^4.3.2"
"@vitejs/plugin-react": "^4.3.4"
},
"devDependencies": {
"@aklinker1/check": "^1.4.5",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"publint": "^0.2.11",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.6.2",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"publint": "^0.2.12",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.6.3",
"unbuild": "^2.0.0",
"wxt": "workspace:*"
}
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## v1.1.3
[compare changes](https://github.com/wxt-dev/wxt/compare/module-solid-v1.1.2...module-solid-v1.1.3)
### 🩹 Fixes
- Use `config:resolved` hook to update config instead of `ready` ([#1178](https://github.com/wxt-dev/wxt/pull/1178))
### 🏡 Chore
- Add `oxlint` for linting ([#947](https://github.com/wxt-dev/wxt/pull/947))
- Upgrade all non-major dependencies ([#1040](https://github.com/wxt-dev/wxt/pull/1040))
- **deps:** Upgrade all non-major dependencies ([#1164](https://github.com/wxt-dev/wxt/pull/1164))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
## v1.1.2
[compare changes](https://github.com/wxt-dev/wxt/compare/module-solid-v1.1.1...module-solid-v1.1.2)
+1 -1
View File
@@ -18,7 +18,7 @@ export default defineWxtModule<SolidModuleOptions>({
addImportPreset(wxt, 'solid-js');
// Enable auto-imports for JSX files
wxt.hooks.hook('ready', (wxt) => {
wxt.hook('config:resolved', (wxt) => {
if (wxt.config.imports === false) return;
wxt.config.imports.dirsScanOptions ??= {};
+5 -5
View File
@@ -17,7 +17,7 @@
"email": "aaronklinker1+wxt@gmail.com"
},
"license": "MIT",
"version": "1.1.2",
"version": "1.1.3",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
@@ -44,16 +44,16 @@
"prepare": "buildc --deps-only -- wxt prepare"
},
"peerDependencies": {
"wxt": ">=0.18.6"
"wxt": ">=0.19.16"
},
"dependencies": {
"vite-plugin-solid": "^2.10.2"
},
"devDependencies": {
"@aklinker1/check": "^1.4.5",
"publint": "^0.2.11",
"solid-js": "^1.9.1",
"typescript": "^5.6.2",
"publint": "^0.2.12",
"solid-js": "^1.9.3",
"typescript": "^5.6.3",
"unbuild": "^2.0.0",
"wxt": "workspace:*"
}
+63
View File
@@ -1,5 +1,68 @@
# Changelog
## v2.0.3
[compare changes](https://github.com/wxt-dev/wxt/compare/module-svelte-v2.0.2...module-svelte-v2.0.3)
### 🩹 Fixes
- Upgrade `vite-plugin-svelte` to support Vite 6 ([#1375](https://github.com/wxt-dev/wxt/pull/1375))
### ❤️ Contributors
- Eli ([@lishaduck](http://github.com/lishaduck))
## v2.0.2
[compare changes](https://github.com/wxt-dev/wxt/compare/module-svelte-v2.0.1...module-svelte-v2.0.2)
### 🩹 Fixes
- Svelte production errors and warnings on resolve conditions ([#1283](https://github.com/wxt-dev/wxt/pull/1283))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
## v2.0.1
[compare changes](https://github.com/wxt-dev/wxt/compare/module-svelte-v2.0.0...module-svelte-v2.0.1)
### 🩹 Fixes
- Modify dev `vite.resolve.conditions` to support Vite 6 + Svelte 5 ([#1230](https://github.com/wxt-dev/wxt/pull/1230))
### 🏡 Chore
- **deps:** Upgrade all non-major dependencies ([#1164](https://github.com/wxt-dev/wxt/pull/1164))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
## v2.0.0
[⚠️ breaking changes](https://wxt.dev/guide/upgrade-guide/wxt) &bull; [compare changes](https://github.com/wxt-dev/wxt/compare/module-svelte-v1.0.1...module-svelte-v2.0.0)
### 🚀 Enhancements
- ⚠️ Svelte 5 support ([#1104](https://github.com/wxt-dev/wxt/pull/1104))
### 🏡 Chore
- Add `oxlint` for linting ([#947](https://github.com/wxt-dev/wxt/pull/947))
- Upgrade all non-major dependencies ([#1040](https://github.com/wxt-dev/wxt/pull/1040))
#### ⚠️ Breaking Changes
Upgraded `@sveltejs/vite-plugin-svelte` from v3 to v4. This drops support for Svelte 4 and below. To continue using older versions of Svelte, use v1 of this module.
To upgrade to svelte 5, just install `svelte@5`.
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
## v1.0.1
[compare changes](https://github.com/wxt-dev/wxt/compare/module-svelte-v1.0.0...module-svelte-v1.0.1)
+6 -5
View File
@@ -17,7 +17,7 @@
"email": "aaronklinker1+wxt@gmail.com"
},
"license": "MIT",
"version": "1.0.1",
"version": "2.0.3",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
@@ -42,15 +42,16 @@
"check": "pnpm build && check"
},
"peerDependencies": {
"wxt": ">=0.18.6"
"wxt": ">=0.18.6",
"svelte": ">=5"
},
"dependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.2"
"@sveltejs/vite-plugin-svelte": "^4.0.0 || ^5.0.0"
},
"devDependencies": {
"@aklinker1/check": "^1.4.5",
"publint": "^0.2.11",
"typescript": "^5.6.2",
"publint": "^0.2.12",
"typescript": "^5.6.3",
"unbuild": "^2.0.0",
"wxt": "workspace:*"
}
+4 -1
View File
@@ -12,7 +12,7 @@ export default defineWxtModule<SvelteModuleOptions>({
setup(wxt, options) {
const { vite } = options ?? {};
addViteConfig(wxt, () => ({
addViteConfig(wxt, ({ mode }) => ({
plugins: [
svelte({
// Using a svelte.config.js file causes a segmentation fault when importing the file
@@ -21,6 +21,9 @@ export default defineWxtModule<SvelteModuleOptions>({
...vite,
}),
],
resolve: {
conditions: ['browser', mode],
},
}));
addImportPreset(wxt, 'svelte');
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## v1.0.2
[compare changes](https://github.com/wxt-dev/wxt/compare/module-vue-v1.0.1...module-vue-v1.0.2)
### 🩹 Fixes
- Use `config:resolved` hook to update config instead of `ready` ([#1178](https://github.com/wxt-dev/wxt/pull/1178))
### 🏡 Chore
- Add `oxlint` for linting ([#947](https://github.com/wxt-dev/wxt/pull/947))
- **deps:** Bump @vitejs/plugin-vue from 5.1.1 to 5.1.4 ([#1020](https://github.com/wxt-dev/wxt/pull/1020))
- Upgrade all non-major dependencies ([#1040](https://github.com/wxt-dev/wxt/pull/1040))
- **deps:** Upgrade all non-major dependencies ([#1164](https://github.com/wxt-dev/wxt/pull/1164))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
## v1.0.1
[compare changes](https://github.com/wxt-dev/wxt/compare/module-vue-v1.0.0...module-vue-v1.0.1)
+5 -5
View File
@@ -17,7 +17,7 @@
"email": "aaronklinker1+wxt@gmail.com"
},
"license": "MIT",
"version": "1.0.1",
"version": "1.0.2",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
@@ -42,15 +42,15 @@
"check": "pnpm build && check"
},
"peerDependencies": {
"wxt": ">=0.18.6"
"wxt": ">=0.19.16"
},
"dependencies": {
"@vitejs/plugin-vue": "^5.1.4"
"@vitejs/plugin-vue": "^5.2.0"
},
"devDependencies": {
"@aklinker1/check": "^1.4.5",
"publint": "^0.2.11",
"typescript": "^5.6.2",
"publint": "^0.2.12",
"typescript": "^5.6.3",
"unbuild": "^2.0.0",
"wxt": "workspace:*"
}
+1 -1
View File
@@ -19,7 +19,7 @@ export default defineWxtModule<VueModuleOptions>({
}));
// Enable auto-imports in template files
wxt.hooks.hook('ready', (wxt) => {
wxt.hook('config:resolved', (wxt) => {
if (!wxt.config.imports) return;
wxt.config.imports.addons ??= {};
+51
View File
@@ -0,0 +1,51 @@
# Changelog
## v1.1.0
[compare changes](https://github.com/wxt-dev/wxt/compare/storage-v1.0.1...storage-v1.1.0)
### 🚀 Enhancements
- Add `storage.clear` ([#1368](https://github.com/wxt-dev/wxt/pull/1368))
### 📖 Documentation
- Update link ([654a54a](https://github.com/wxt-dev/wxt/commit/654a54a))
### ❤️ Contributors
- Chengxi ([@chengxilo](http://github.com/chengxilo))
- Aaron ([@aklinker1](http://github.com/aklinker1))
## v1.0.1
[compare changes](https://github.com/wxt-dev/wxt/compare/storage-v1.0.0...storage-v1.0.1)
### 🩹 Fixes
- Use `browser` for mv2 storage ([#1200](https://github.com/wxt-dev/wxt/pull/1200))
### 📖 Documentation
- Cleanup changelog ([f5b7f7e](https://github.com/wxt-dev/wxt/commit/f5b7f7e))
### 🏡 Chore
- Init changelog for storage package ([6fc227b](https://github.com/wxt-dev/wxt/commit/6fc227b))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
## v1.0.0
Extracted `wxt/storage` into it's own package, `@wxt-dev/storage`!
It's still shipped inside WXT and accessible via `wxt/storage`, but now:
- Non-WXT projects can use the storage wrapper.
- We can make breaking changes to the API separately.
[Read the docs](https://wxt.dev/storage.html) for more details.
> This is apart of the v1.0 initiative for WXT.
+36
View File
@@ -0,0 +1,36 @@
# WXT Storage
[Changelog](https://github.com/wxt-dev/wxt/blob/main/packages/storage/CHANGELOG.md) &bull; [Docs](https://wxt.dev/storage.html)
A simplified wrapper around the extension storage APIs.
## Installation
### With WXT
This module is built-in to WXT, so you don't need to install anything.
```ts
import { storage } from 'wxt/storage';
```
If you use auto-imports, `storage` is auto-imported for you, so you don't even need to import it!
### Without WXT
Install the NPM package:
```sh
npm i @wxt-dev/storage
pnpm add @wxt-dev/storage
yarn add @wxt-dev/storage
bun add @wxt-dev/storage
```
```ts
import { storage } from '@wxt-dev/storage';
```
## Usage
Read full docs on the [documentation website](https://wxt.dev/storage.html).
+56
View File
@@ -0,0 +1,56 @@
{
"name": "@wxt-dev/storage",
"description": "Web extension storage API provided by WXT, supports all browsers.",
"version": "1.1.0",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/wxt-dev/wxt.git",
"directory": "packages/storage"
},
"homepage": "https://wxt.dev/storage.html",
"keywords": [
"wxt",
"storage",
"extension",
"addon",
"chrome",
"firefox",
"edge"
],
"author": {
"name": "Aaron Klinker",
"email": "aaronklinker1+wxt@gmail.com"
},
"license": "MIT",
"scripts": {
"build": "buildc -- unbuild",
"check": "buildc --deps-only -- check",
"test": "buildc --deps-only -- vitest"
},
"dependencies": {
"async-mutex": "^0.5.0",
"dequal": "^2.0.3"
},
"devDependencies": {
"@aklinker1/check": "^1.4.5",
"@types/chrome": "^0.0.268",
"@webext-core/fake-browser": "^1.3.1",
"oxlint": "^0.9.9",
"publint": "^0.2.11",
"typescript": "^5.6.2",
"unbuild": "^2.0.0",
"vitest": "^2.0.0"
},
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
}
},
"files": [
"dist"
]
}
@@ -1,7 +1,6 @@
import { fakeBrowser } from '@webext-core/fake-browser';
import { describe, it, expect, beforeEach, vi, expectTypeOf } from 'vitest';
import { browser } from 'wxt/browser';
import { WxtStorageItem, storage } from '../storage';
import { MigrationError, type WxtStorageItem, storage } from '../index';
/**
* This works because fakeBrowser is synchronous, and is will finish any number of chained
@@ -38,7 +37,7 @@ describe('Storage Utils', () => {
expect(actual).toBe(expected);
});
it('should return the value if multiple : are use in the key', async () => {
it('should return the value if multiple : are used in the key', async () => {
const expected = 'value';
await fakeBrowser.storage[storageArea].set({ 'some:key': expected });
@@ -64,27 +63,97 @@ describe('Storage Utils', () => {
});
describe('getItems', () => {
it('should return an array of values', async () => {
const expected = [
{ key: `${storageArea}:count`, value: 234 },
{ key: `${storageArea}:installDate`, value: null },
{ key: `${storageArea}:otherValue`, value: 345 },
] as const;
const params = [
expected[0].key,
expected[1].key,
{
key: expected[2].key,
options: { defaultValue: expected[2].value },
},
];
it('should get values from multiple storage keys', async () => {
const item1 = {
key: `${storageArea}:one`,
expectedValue: 1,
} as const;
const item2 = {
key: `${storageArea}:two`,
expectedValue: null,
} as const;
await fakeBrowser.storage[storageArea].set({
count: expected[0].value,
one: item1.expectedValue,
});
const actual = await storage.getItems(params);
const actual = await storage.getItems([item1.key, item2.key]);
expect(actual).toEqual(expected);
expect(actual).toEqual([
{ key: item1.key, value: item1.expectedValue },
{ key: item2.key, value: item2.expectedValue },
]);
});
it('should get values from multiple storage items', async () => {
const item1 = storage.defineItem(`${storageArea}:one`);
const expectedValue1 = 1;
const item2 = storage.defineItem(`${storageArea}:two`);
const expectedValue2 = null;
await fakeBrowser.storage[storageArea].set({
one: expectedValue1,
});
const actual = await storage.getItems([item1, item2]);
expect(actual).toEqual([
{ key: item1.key, value: expectedValue1 },
{ key: item2.key, value: expectedValue2 },
]);
});
it('should get values for a combination of different input types', async () => {
const key1 = `${storageArea}:one` as const;
const expectedValue1 = 1;
const item2 = storage.defineItem<number>(`${storageArea}:two`);
const expectedValue2 = 2;
await fakeBrowser.storage[storageArea].set({
one: expectedValue1,
two: expectedValue2,
});
const actual = await storage.getItems([key1, item2]);
expect(actual).toEqual([
{ key: key1, value: expectedValue1 },
{ key: item2.key, value: expectedValue2 },
]);
});
it('should return fallback values for keys when provided', async () => {
const key1 = `${storageArea}:one` as const;
const expectedValue1 = null;
const key2 = `${storageArea}:two` as const;
const fallback2 = 2;
const expectedValue2 = fallback2;
const actual = await storage.getItems([
key1,
{ key: key2, options: { fallback: fallback2 } },
]);
expect(actual).toEqual([
{ key: key1, value: expectedValue1 },
{ key: key2, value: expectedValue2 },
]);
});
it('should return fallback values for items when provided', async () => {
const item1 = storage.defineItem<number>(`${storageArea}:one`);
const expectedValue1 = null;
const item2 = storage.defineItem(`${storageArea}:two`, {
fallback: 2,
});
const expectedValue2 = item2.fallback;
const actual = await storage.getItems([item1, item2]);
expect(actual).toEqual([
{ key: item1.key, value: expectedValue1 },
{ key: item2.key, value: expectedValue2 },
]);
});
});
@@ -153,7 +222,7 @@ describe('Storage Utils', () => {
describe('setMeta', () => {
it('should set metadata at key+$', async () => {
const existing = { v: 1 };
await browser.storage[storageArea].set({ count$: existing });
await chrome.storage[storageArea].set({ count$: existing });
const newValues = {
date: Date.now(),
};
@@ -169,7 +238,7 @@ describe('Storage Utils', () => {
'should remove any properties set to %s',
async (version) => {
const existing = { v: 1 };
await browser.storage[storageArea].set({ count$: existing });
await chrome.storage[storageArea].set({ count$: existing });
const expected = {};
await storage.setMeta(`${storageArea}:count`, { v: version });
@@ -180,6 +249,74 @@ describe('Storage Utils', () => {
);
});
describe('setMetas', () => {
it('should set key metadata correctly', async () => {
const key1 = `${storageArea}:one` as const;
const initialMeta1 = {};
const setMeta1 = { v: 1 };
const expectedMeta1 = setMeta1;
const key2 = `${storageArea}:two` as const;
const initialMeta2 = { v: 1 };
const setMeta2 = { v: 2 };
const expectedMeta2 = setMeta2;
const key3 = `${storageArea}:three` as const;
const initialMeta3 = { v: 1 };
const setMeta3 = { d: Date.now() };
const expectedMeta3 = { ...initialMeta3, ...setMeta3 };
await fakeBrowser.storage[storageArea].set({
one$: initialMeta1,
two$: initialMeta2,
three$: initialMeta3,
});
await storage.setMetas([
{ key: key1, meta: setMeta1 },
{ key: key2, meta: setMeta2 },
{ key: key3, meta: setMeta3 },
]);
expect(await storage.getMeta(key1)).toEqual(expectedMeta1);
expect(await storage.getMeta(key2)).toEqual(expectedMeta2);
expect(await storage.getMeta(key3)).toEqual(expectedMeta3);
});
it('should set item metadata correctly', async () => {
const item1 = storage.defineItem(`${storageArea}:one`);
const initialMeta1 = {};
const setMeta1 = { v: 1 };
const expectedMeta1 = setMeta1;
const item2 = storage.defineItem(`${storageArea}:two`);
const initialMeta2 = { v: 1 };
const setMeta2 = { v: 2 };
const expectedMeta2 = setMeta2;
const item3 = storage.defineItem(`${storageArea}:three`);
const initialMeta3 = { v: 1 };
const setMeta3 = { d: Date.now() };
const expectedMeta3 = { ...initialMeta3, ...setMeta3 };
await fakeBrowser.storage[storageArea].set({
one$: initialMeta1,
two$: initialMeta2,
three$: initialMeta3,
});
await storage.setMetas([
{ item: item1, meta: setMeta1 },
{ item: item2, meta: setMeta2 },
{ item: item3, meta: setMeta3 },
]);
expect(await item1.getMeta()).toEqual(expectedMeta1);
expect(await item2.getMeta()).toEqual(expectedMeta2);
expect(await item3.getMeta()).toEqual(expectedMeta3);
});
});
describe('removeItem', () => {
it('should remove the key from storage', async () => {
await fakeBrowser.storage[storageArea].set({ count: 456 });
@@ -219,29 +356,148 @@ describe('Storage Utils', () => {
});
describe('removeItems', () => {
it('should remove multiple items', async () => {
it('should remove multiple keys', async () => {
const key1 = `${storageArea}:one` as const;
const key2 = `${storageArea}:two` as const;
const key3 = `${storageArea}:three` as const;
await fakeBrowser.storage[storageArea].set({
['one']: '1',
['two']: null,
['two$']: { v: 1 },
['three']: '1',
['three$']: { v: 1 },
one: 1,
two: 2,
});
await storage.removeItems([key1, key2]);
expect(await storage.getItem(key1)).toBeNull();
expect(await storage.getItem(key2)).toBeNull();
});
it('should remove multiple keys and metadata when requested', async () => {
const key1 = `${storageArea}:one` as const;
const key2 = `${storageArea}:two` as const;
await fakeBrowser.storage[storageArea].set({
one: 1,
one$: { v: 1 },
two: 2,
two$: { v: 1 },
});
await storage.removeItems([
key1,
key2,
{ key: key3, options: { removeMeta: true } },
{ key: key2, options: { removeMeta: true } },
]);
expect(await storage.getItem(key1)).toBeNull();
expect(await storage.getMeta(key1)).toEqual({ v: 1 });
expect(await storage.getItem(key2)).toBeNull();
expect(await storage.getMeta(key2)).toEqual({ v: 1 });
expect(await storage.getItem(key3)).toBeNull();
expect(await storage.getMeta(key3)).toEqual({});
expect(await storage.getMeta(key2)).toEqual({});
});
it('should remove multiple items', async () => {
const item1 = storage.defineItem(`${storageArea}:one`);
const item2 = storage.defineItem(`${storageArea}:two`);
await fakeBrowser.storage[storageArea].set({
one: 1,
two: 2,
});
await storage.removeItems([item1, item2]);
expect(await item1.getValue()).toBeNull();
expect(await item2.getValue()).toBeNull();
});
it('should remove multiple items and metadata when requested', async () => {
const item1 = storage.defineItem(`${storageArea}:one`);
const item2 = storage.defineItem(`${storageArea}:two`);
await fakeBrowser.storage[storageArea].set({
one: 1,
one$: { v: 1 },
two: 2,
two$: { v: 1 },
});
await storage.removeItems([
item1,
{ item: item2, options: { removeMeta: true } },
]);
expect(await item1.getValue()).toBeNull();
expect(await item1.getMeta()).toEqual({ v: 1 });
expect(await item2.getValue()).toBeNull();
expect(await item2.getMeta()).toEqual({});
});
it('should remove multiple items', async () => {
const item1 = storage.defineItem(`${storageArea}:one`);
const item2 = storage.defineItem(`${storageArea}:two`);
await fakeBrowser.storage.local.set({
one: 1,
two: 2,
});
await storage.removeItems([item1, item2]);
expect(await item1.getValue()).toBeNull();
expect(await item2.getValue()).toBeNull();
});
it('should remove items using { item: WxtStorageItem, options?: RemoveItemOptions } objects', async () => {
const item1 = storage.defineItem('local:item1');
const item2 = storage.defineItem('session:item2');
await item1.setValue('value1');
await item1.setMeta({ v: 1 });
await item2.setValue('value2');
await item2.setMeta({ v: 1 });
await storage.removeItems([
{ item: item1, options: { removeMeta: true } },
{ item: item2, options: { removeMeta: false } },
]);
expect(await item1.getValue()).toBeNull();
expect(await item1.getMeta()).toEqual({});
expect(await item2.getValue()).toBeNull();
expect(await item2.getMeta()).toEqual({ v: 1 });
});
it('should handle a mix of different input types', async () => {
const item1 = storage.defineItem('local:item1');
const item2 = storage.defineItem('session:item2');
const item3 = storage.defineItem('local:item3');
await item1.setValue('value1');
await item2.setValue('value2');
await item3.setValue('value3');
await storage.removeItems([
'local:item1',
item2,
{ key: 'local:item3', options: { removeMeta: true } },
]);
expect(await storage.getItem('local:item1')).toBeNull();
expect(await item2.getValue()).toBeNull();
expect(await item3.getValue()).toBeNull();
expect(await item3.getMeta()).toEqual({});
});
it('should not throw an error when removing non-existent items', async () => {
const item = storage.defineItem('local:non_existent');
await expect(storage.removeItems([item])).resolves.not.toThrow();
});
});
describe('clear', () => {
it('should remove all items', async () => {
await fakeBrowser.storage[storageArea].set({
one: 1,
two: 2,
});
await storage.clear(storageArea);
expect(await fakeBrowser.storage[storageArea].get()).toEqual({});
});
});
@@ -400,6 +656,22 @@ describe('Storage Utils', () => {
},
);
describe('Invalid storage areas', () => {
it('should not accept keys without a valid storage area prefix', async () => {
// @ts-expect-error
await storage.getItem('test').catch(() => {});
// @ts-expect-error
await storage.getItem('loca:test').catch(() => {});
});
it('should throw an error when using an invalid storage area', async () => {
// @ts-expect-error: Test passes if there is a type error here
await expect(storage.getItem('invalidArea:key')).rejects.toThrow(
'Invalid area',
);
});
});
describe('defineItem', () => {
describe('versioning', () => {
it('should migrate values to the latest when a version upgrade is detected', async () => {
@@ -553,6 +825,28 @@ describe('Storage Utils', () => {
'Version downgrade detected (v2 -> v1) for "local:count"',
);
});
it('should throw an error when defining an item with an invalid version', () => {
expect(() => storage.defineItem('local:key', { version: 0 })).toThrow(
'Storage item version cannot be less than 1',
);
});
it('should handle errors in migration functions', async () => {
const cause = Error('Some error');
const expectedError = new MigrationError('local:key', 2, { cause });
const item = storage.defineItem<number>('local:key', {
version: 3,
migrations: {
2: () => {
throw cause;
},
},
});
await fakeBrowser.storage.local.set({ key: 1, key$: { v: 1 } });
await expect(item.migrate()).rejects.toThrow(expectedError);
});
});
describe('getValue', () => {
@@ -872,6 +1166,24 @@ describe('Storage Utils', () => {
await expect(storage.getItem('local:test')).resolves.toBe(expected);
});
it("should re-initialize a value on the next call to getValue if it's been removed", async () => {
const init = vi.fn().mockImplementation(Math.random);
const item = storage.defineItem<number>('local:key', { init });
await waitForInit();
await item.removeValue();
// Make sure it's actually blank before running the test
expect(await chrome.storage.local.get()).toEqual({});
init.mockClear();
const [value1, value2] = await Promise.all([
item.getValue(),
item.getValue(),
]);
expect(init).toBeCalledTimes(1);
expect(value1).toBe(value2);
});
});
describe('types', () => {
@@ -896,12 +1208,173 @@ describe('Storage Utils', () => {
});
});
describe('types', () => {
it('should not accept keys without a valid storage area prefix', async () => {
// @ts-expect-error: Test passes if there is a type error here
await storage.getItem('test').catch(() => {});
// @ts-expect-error
await storage.getItem('loca:test').catch(() => {});
describe('Multiple Storage Areas', () => {
describe('getItems', () => {
it('should get the values of multiple storage items efficiently', async () => {
const item1 = storage.defineItem<number>('local:item1');
const item2 = storage.defineItem<string>('session:item2');
const item3 = storage.defineItem<boolean>('local:item3');
await item1.setValue(42);
await item2.setValue('hello');
await item3.setValue(false);
const localGetSpy = vi.spyOn(fakeBrowser.storage.local, 'get');
const sessionGetSpy = vi.spyOn(fakeBrowser.storage.session, 'get');
const values = await storage.getItems([item1, item2, item3]);
expect(values).toEqual([
{
key: 'local:item1',
value: 42,
},
{
key: 'session:item2',
value: 'hello',
},
{
key: 'local:item3',
value: false,
},
]);
expect(localGetSpy).toBeCalledTimes(1);
expect(localGetSpy).toBeCalledWith(['item1', 'item3']);
expect(sessionGetSpy).toBeCalledTimes(1);
expect(sessionGetSpy).toBeCalledWith(['item2']);
});
it('should return values in the same order as input', async () => {
const item1 = storage.defineItem<number>('local:item1');
const item2 = storage.defineItem<string>('session:item2');
const item3 = storage.defineItem<boolean>('local:item3');
expect(await storage.getItems([item1, item2, item3])).toEqual([
{ key: item1.key, value: null },
{ key: item2.key, value: null },
{ key: item3.key, value: null },
]);
expect(await storage.getItems([item3, item2, item1])).toEqual([
{ key: item3.key, value: null },
{ key: item2.key, value: null },
{ key: item1.key, value: null },
]);
});
});
describe('getMetas', () => {
it('should get the metadata of multiple storage items efficiently', async () => {
const item1 = storage.defineItem<number, { v: number }>('local:item1');
const item2 = storage.defineItem<string, { date: number }>(
'session:item2',
);
const item3 = storage.defineItem<boolean, { flag: boolean }>(
'local:item3',
);
await item1.setMeta({ v: 1 });
await item2.setMeta({ date: 1234567890 });
// item3 has no meta
const localGetSpy = vi.spyOn(fakeBrowser.storage.local, 'get');
const sessionGetSpy = vi.spyOn(fakeBrowser.storage.session, 'get');
const metas = await storage.getMetas([item1, item2, item3]);
expect(metas).toEqual([
{ key: item1.key, meta: { v: 1 } },
{ key: item2.key, meta: { date: 1234567890 } },
{ key: item3.key, meta: {} },
]);
expect(localGetSpy).toBeCalledTimes(1);
expect(localGetSpy).toBeCalledWith(['item1$', 'item3$']);
expect(sessionGetSpy).toBeCalledTimes(1);
expect(sessionGetSpy).toBeCalledWith(['item2$']);
});
it('should return the metadata in the same order as input', async () => {
const item1 = storage.defineItem<number, { v: number }>('local:item1');
const item2 = storage.defineItem<string, { date: number }>(
'session:item2',
);
const item3 = storage.defineItem<boolean, { flag: boolean }>(
'local:item3',
);
expect(await storage.getMetas([item1, item2, item3])).toEqual([
{ key: item1.key, meta: {} },
{ key: item2.key, meta: {} },
{ key: item3.key, meta: {} },
]);
expect(await storage.getMetas([item3, item2, item1])).toEqual([
{ key: item3.key, meta: {} },
{ key: item2.key, meta: {} },
{ key: item1.key, meta: {} },
]);
});
});
describe('setItems', () => {
it('should set the values of multiple storage items efficiently', async () => {
const item1 = storage.defineItem<number>('local:item1');
const value1 = 100;
const item2 = storage.defineItem<string>('session:item2');
const value2 = 'test';
const item3 = storage.defineItem<boolean>('local:item3');
const value3 = true;
const localSetSpy = vi.spyOn(fakeBrowser.storage.local, 'set');
const sessionSetSpy = vi.spyOn(fakeBrowser.storage.session, 'set');
await storage.setItems([
{ item: item1, value: value1 },
{ item: item2, value: value2 },
{ item: item3, value: value3 },
]);
expect(localSetSpy).toBeCalledTimes(1);
expect(localSetSpy).toBeCalledWith({ item1: value1, item3: value3 });
expect(sessionSetSpy).toBeCalledTimes(1);
expect(sessionSetSpy).toBeCalledWith({ item2: value2 });
});
});
describe('setMetas', () => {
it('should set metadata efficiently', async () => {
const item1 = storage.defineItem<number, { v: number }>('local:one');
const item2 = storage.defineItem<string, { v: number }>('session:two');
const item3 = storage.defineItem<boolean, { v: number }>('local:three');
await waitForInit();
const localGetSpy = vi.spyOn(fakeBrowser.storage.local, 'get');
const sessionGetSpy = vi.spyOn(fakeBrowser.storage.session, 'get');
const localSetSpy = vi.spyOn(fakeBrowser.storage.local, 'set');
const sessionSetSpy = vi.spyOn(fakeBrowser.storage.session, 'set');
await storage.setMetas([
{ item: item1, meta: { v: 1 } },
{ item: item2, meta: { v: 2 } },
{ item: item3, meta: { v: 3 } },
]);
console.log(localGetSpy.mock.calls);
expect(localGetSpy).toBeCalledTimes(1);
expect(localGetSpy).toBeCalledWith(['one$', 'three$']);
expect(sessionGetSpy).toBeCalledTimes(1);
expect(sessionGetSpy).toBeCalledWith(['two$']);
expect(localSetSpy).toBeCalledTimes(1);
expect(localSetSpy).toBeCalledWith({
one$: { v: 1 },
three$: { v: 3 },
});
expect(sessionSetSpy).toBeCalledTimes(1);
expect(sessionSetSpy).toBeCalledWith({
two$: { v: 2 },
});
});
});
it('should return a nullable type when getItem is called without a fallback', async () => {
+892
View File
@@ -0,0 +1,892 @@
/// <reference types="chrome" />
/**
* Simplified storage APIs with support for versioned fields, snapshots, metadata, and item definitions.
*
* See [the guide](https://wxt.dev/storage.html) for more information.
* @module @wxt-dev/storage
*/
import { dequal } from 'dequal/lite';
import { Mutex } from 'async-mutex';
const browser: typeof chrome =
// @ts-expect-error
globalThis.browser?.runtime?.id == null
? globalThis.chrome
: // @ts-expect-error
globalThis.browser;
export const storage = createStorage();
function createStorage(): WxtStorage {
const drivers: Record<StorageArea, WxtStorageDriver> = {
local: createDriver('local'),
session: createDriver('session'),
sync: createDriver('sync'),
managed: createDriver('managed'),
};
const getDriver = (area: StorageArea) => {
const driver = drivers[area];
if (driver == null) {
const areaNames = Object.keys(drivers).join(', ');
throw Error(`Invalid area "${area}". Options: ${areaNames}`);
}
return driver;
};
const resolveKey = (key: StorageItemKey) => {
const deliminatorIndex = key.indexOf(':');
const driverArea = key.substring(0, deliminatorIndex) as StorageArea;
const driverKey = key.substring(deliminatorIndex + 1);
if (driverKey == null)
throw Error(
`Storage key should be in the form of "area:key", but received "${key}"`,
);
return {
driverArea,
driverKey,
driver: getDriver(driverArea),
};
};
const getMetaKey = (key: string) => key + '$';
const mergeMeta = (oldMeta: any, newMeta: any): any => {
const newFields = { ...oldMeta };
Object.entries(newMeta).forEach(([key, value]) => {
if (value == null) delete newFields[key];
else newFields[key] = value;
});
return newFields;
};
const getValueOrFallback = (value: any, fallback: any) =>
value ?? fallback ?? null;
const getMetaValue = (properties: any) =>
typeof properties === 'object' && !Array.isArray(properties)
? properties
: {};
const getItem = async (
driver: WxtStorageDriver,
driverKey: string,
opts: GetItemOptions<any> | undefined,
) => {
const res = await driver.getItem<any>(driverKey);
return getValueOrFallback(res, opts?.fallback ?? opts?.defaultValue);
};
const getMeta = async (driver: WxtStorageDriver, driverKey: string) => {
const metaKey = getMetaKey(driverKey);
const res = await driver.getItem<any>(metaKey);
return getMetaValue(res);
};
const setItem = async (
driver: WxtStorageDriver,
driverKey: string,
value: any,
) => {
await driver.setItem(driverKey, value ?? null);
};
const setMeta = async (
driver: WxtStorageDriver,
driverKey: string,
properties: any | undefined,
) => {
const metaKey = getMetaKey(driverKey);
const existingFields = getMetaValue(await driver.getItem(metaKey));
await driver.setItem(metaKey, mergeMeta(existingFields, properties));
};
const removeItem = async (
driver: WxtStorageDriver,
driverKey: string,
opts: RemoveItemOptions | undefined,
) => {
await driver.removeItem(driverKey);
if (opts?.removeMeta) {
const metaKey = getMetaKey(driverKey);
await driver.removeItem(metaKey);
}
};
const removeMeta = async (
driver: WxtStorageDriver,
driverKey: string,
properties: string | string[] | undefined,
) => {
const metaKey = getMetaKey(driverKey);
if (properties == null) {
await driver.removeItem(metaKey);
} else {
const newFields = getMetaValue(await driver.getItem(metaKey));
[properties].flat().forEach((field) => delete newFields[field]);
await driver.setItem(metaKey, newFields);
}
};
const watch = (
driver: WxtStorageDriver,
driverKey: string,
cb: WatchCallback<any>,
) => {
return driver.watch(driverKey, cb);
};
const storage: WxtStorage = {
getItem: async (key, opts) => {
const { driver, driverKey } = resolveKey(key);
return await getItem(driver, driverKey, opts);
},
getItems: async (keys) => {
const areaToKeyMap = new Map<StorageArea, string[]>();
const keyToOptsMap = new Map<string, GetItemOptions<any> | undefined>();
const orderedKeys: StorageItemKey[] = [];
keys.forEach((key) => {
let keyStr: StorageItemKey;
let opts: GetItemOptions<any> | undefined;
if (typeof key === 'string') {
// key: string
keyStr = key;
} else if ('getValue' in key) {
// key: WxtStorageItem
keyStr = key.key;
opts = { fallback: key.fallback };
} else {
// key: { key, options }
keyStr = key.key;
opts = key.options;
}
orderedKeys.push(keyStr);
const { driverArea, driverKey } = resolveKey(keyStr);
const areaKeys = areaToKeyMap.get(driverArea) ?? [];
areaToKeyMap.set(driverArea, areaKeys.concat(driverKey));
keyToOptsMap.set(keyStr, opts);
});
const resultsMap = new Map<StorageItemKey, any>();
await Promise.all(
Array.from(areaToKeyMap.entries()).map(async ([driverArea, keys]) => {
const driverResults = await drivers[driverArea].getItems(keys);
driverResults.forEach((driverResult) => {
const key = `${driverArea}:${driverResult.key}` as StorageItemKey;
const opts = keyToOptsMap.get(key);
const value = getValueOrFallback(
driverResult.value,
opts?.fallback ?? opts?.defaultValue,
);
resultsMap.set(key, value);
});
}),
);
return orderedKeys.map((key) => ({
key,
value: resultsMap.get(key),
}));
},
getMeta: async (key) => {
const { driver, driverKey } = resolveKey(key);
return await getMeta(driver, driverKey);
},
getMetas: async (args) => {
const keys = args.map((arg) => {
const key = typeof arg === 'string' ? arg : arg.key;
const { driverArea, driverKey } = resolveKey(key);
return {
key,
driverArea,
driverKey,
driverMetaKey: getMetaKey(driverKey),
};
});
const areaToDriverMetaKeysMap = keys.reduce<
Partial<Record<StorageArea, (typeof keys)[number][]>>
>((map, key) => {
map[key.driverArea] ??= [];
map[key.driverArea]!.push(key);
return map;
}, {});
const resultsMap: Record<string, any> = {};
await Promise.all(
Object.entries(areaToDriverMetaKeysMap).map(async ([area, keys]) => {
const areaRes = await browser.storage[area as StorageArea].get(
keys.map((key) => key.driverMetaKey),
);
keys.forEach((key) => {
resultsMap[key.key] = areaRes[key.driverMetaKey] ?? {};
});
}),
);
return keys.map((key) => ({
key: key.key,
meta: resultsMap[key.key],
}));
},
setItem: async (key, value) => {
const { driver, driverKey } = resolveKey(key);
await setItem(driver, driverKey, value);
},
setItems: async (items) => {
const areaToKeyValueMap: Partial<
Record<StorageArea, Array<{ key: string; value: any }>>
> = {};
items.forEach((item) => {
const { driverArea, driverKey } = resolveKey(
'key' in item ? item.key : item.item.key,
);
areaToKeyValueMap[driverArea] ??= [];
areaToKeyValueMap[driverArea].push({
key: driverKey,
value: item.value,
});
});
await Promise.all(
Object.entries(areaToKeyValueMap).map(async ([driverArea, values]) => {
const driver = getDriver(driverArea as StorageArea);
await driver.setItems(values);
}),
);
},
setMeta: async (key, properties) => {
const { driver, driverKey } = resolveKey(key);
await setMeta(driver, driverKey, properties);
},
setMetas: async (items) => {
const areaToMetaUpdatesMap: Partial<
Record<StorageArea, { key: string; properties: any }[]>
> = {};
items.forEach((item) => {
const { driverArea, driverKey } = resolveKey(
'key' in item ? item.key : item.item.key,
);
areaToMetaUpdatesMap[driverArea] ??= [];
areaToMetaUpdatesMap[driverArea].push({
key: driverKey,
properties: item.meta,
});
});
await Promise.all(
Object.entries(areaToMetaUpdatesMap).map(
async ([storageArea, updates]) => {
const driver = getDriver(storageArea as StorageArea);
const metaKeys = updates.map(({ key }) => getMetaKey(key));
console.log(storageArea, metaKeys);
const existingMetas = await driver.getItems(metaKeys);
const existingMetaMap = Object.fromEntries(
existingMetas.map(({ key, value }) => [key, getMetaValue(value)]),
);
const metaUpdates = updates.map(({ key, properties }) => {
const metaKey = getMetaKey(key);
return {
key: metaKey,
value: mergeMeta(existingMetaMap[metaKey] ?? {}, properties),
};
});
await driver.setItems(metaUpdates);
},
),
);
},
removeItem: async (key, opts) => {
const { driver, driverKey } = resolveKey(key);
await removeItem(driver, driverKey, opts);
},
removeItems: async (keys) => {
const areaToKeysMap: Partial<Record<StorageArea, string[]>> = {};
keys.forEach((key) => {
let keyStr: StorageItemKey;
let opts: RemoveItemOptions | undefined;
if (typeof key === 'string') {
// key: string
keyStr = key;
} else if ('getValue' in key) {
// key: WxtStorageItem
keyStr = key.key;
} else if ('item' in key) {
// key: { item, options }
keyStr = key.item.key;
opts = key.options;
} else {
// key: { key, options }
keyStr = key.key;
opts = key.options;
}
const { driverArea, driverKey } = resolveKey(keyStr);
areaToKeysMap[driverArea] ??= [];
areaToKeysMap[driverArea].push(driverKey);
if (opts?.removeMeta) {
areaToKeysMap[driverArea].push(getMetaKey(driverKey));
}
});
await Promise.all(
Object.entries(areaToKeysMap).map(async ([driverArea, keys]) => {
const driver = getDriver(driverArea as StorageArea);
await driver.removeItems(keys);
}),
);
},
clear: async (base) => {
const driver = getDriver(base);
await driver.clear();
},
removeMeta: async (key, properties) => {
const { driver, driverKey } = resolveKey(key);
await removeMeta(driver, driverKey, properties);
},
snapshot: async (base, opts) => {
const driver = getDriver(base);
const data = await driver.snapshot();
opts?.excludeKeys?.forEach((key) => {
delete data[key];
delete data[getMetaKey(key)];
});
return data;
},
restoreSnapshot: async (base, data) => {
const driver = getDriver(base);
await driver.restoreSnapshot(data);
},
watch: (key, cb) => {
const { driver, driverKey } = resolveKey(key);
return watch(driver, driverKey, cb);
},
unwatch() {
Object.values(drivers).forEach((driver) => {
driver.unwatch();
});
},
defineItem: (key, opts?: WxtStorageItemOptions<any>) => {
const { driver, driverKey } = resolveKey(key);
const { version: targetVersion = 1, migrations = {} } = opts ?? {};
if (targetVersion < 1) {
throw Error(
'Storage item version cannot be less than 1. Initial versions should be set to 1, not 0.',
);
}
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;
if (currentVersion > targetVersion) {
throw Error(
`Version downgrade detected (v${currentVersion} -> v${targetVersion}) for "${key}"`,
);
}
console.debug(
`[@wxt-dev/storage] Running storage migration for ${key}: v${currentVersion} -> v${targetVersion}`,
);
const migrationsToRun = Array.from(
{ length: targetVersion - currentVersion },
(_, i) => currentVersion + i + 1,
);
let migratedValue = value;
for (const migrateToVersion of migrationsToRun) {
try {
migratedValue =
(await migrations?.[migrateToVersion]?.(migratedValue)) ??
migratedValue;
} catch (err) {
throw Error(`v${migrateToVersion} migration failed for "${key}"`, {
cause: err,
});
}
}
await driver.setItems([
{ key: driverKey, value: migratedValue },
{ key: driverMetaKey, value: { ...meta, v: targetVersion } },
]);
console.debug(
`[@wxt-dev/storage] Storage migration completed for ${key} v${targetVersion}`,
{ migratedValue },
);
};
const migrationsDone =
opts?.migrations == null
? Promise.resolve()
: migrate().catch((err) => {
console.error(
`[@wxt-dev/storage] Migration failed for ${key}`,
err,
);
});
const initMutex = new Mutex();
const getFallback = () => opts?.fallback ?? opts?.defaultValue ?? null;
const getOrInitValue = () =>
initMutex.runExclusive(async () => {
const value = await driver.getItem<any>(driverKey);
// Don't init value if it already exists or the init function isn't provided
if (value != null || opts?.init == null) return value;
const newValue = await opts.init();
await driver.setItem<any>(driverKey, newValue);
return newValue;
});
// Initialize the value once migrations have finished
migrationsDone.then(getOrInitValue);
return {
key,
get defaultValue() {
return getFallback();
},
get fallback() {
return getFallback();
},
getValue: async () => {
await migrationsDone;
if (opts?.init) {
return await getOrInitValue();
} else {
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 ?? getFallback(), oldValue ?? getFallback()),
),
migrate,
};
},
};
return storage;
}
function createDriver(storageArea: StorageArea): WxtStorageDriver {
const getStorageArea = () => {
if (browser.runtime == null) {
throw Error(
[
"'wxt/storage' must be loaded in a web extension environment",
'\n - If thrown during a build, see https://github.com/wxt-dev/wxt/issues/371',
" - If thrown during tests, mock 'wxt/browser' correctly. See https://wxt.dev/guide/go-further/testing.html\n",
].join('\n'),
);
}
if (browser.storage == null) {
throw Error(
"You must add the 'storage' permission to your manifest to use 'wxt/storage'",
);
}
const area = browser.storage[storageArea];
if (area == null)
throw Error(`"browser.storage.${storageArea}" is undefined`);
return area;
};
const watchListeners = new Set<(changes: StorageAreaChanges) => void>();
return {
getItem: async (key) => {
const res = await getStorageArea().get(key);
return res[key];
},
getItems: async (keys) => {
const result = await getStorageArea().get(keys);
return keys.map((key) => ({ key, value: result[key] ?? null }));
},
setItem: async (key, value) => {
if (value == null) {
await getStorageArea().remove(key);
} else {
await getStorageArea().set({ [key]: value });
}
},
setItems: async (values) => {
const map = values.reduce<Record<string, unknown>>(
(map, { key, value }) => {
map[key] = value;
return map;
},
{},
);
await getStorageArea().set(map);
},
removeItem: async (key) => {
await getStorageArea().remove(key);
},
removeItems: async (keys) => {
await getStorageArea().remove(keys);
},
clear: async () => {
await getStorageArea().clear();
},
snapshot: async () => {
return await getStorageArea().get();
},
restoreSnapshot: async (data) => {
await getStorageArea().set(data);
},
watch(key, cb) {
const listener = (changes: StorageAreaChanges) => {
const change = changes[key];
if (change == null) return;
if (dequal(change.newValue, change.oldValue)) return;
cb(change.newValue ?? null, change.oldValue ?? null);
};
getStorageArea().onChanged.addListener(listener);
watchListeners.add(listener);
return () => {
getStorageArea().onChanged.removeListener(listener);
watchListeners.delete(listener);
};
},
unwatch() {
watchListeners.forEach((listener) => {
getStorageArea().onChanged.removeListener(listener);
});
watchListeners.clear();
},
};
}
export interface WxtStorage {
/**
* Get an item from storage, or return `null` if it doesn't exist.
*
* @example
* await storage.getItem<number>("local:installDate");
*/
getItem<TValue>(
key: StorageItemKey,
opts: GetItemOptions<TValue> & { fallback: TValue },
): Promise<TValue>;
getItem<TValue>(
key: StorageItemKey,
opts?: GetItemOptions<TValue>,
): Promise<TValue | null>;
/**
* 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"]);
*/
getItems(
keys: Array<
| StorageItemKey
| WxtStorageItem<any, any>
| { key: StorageItemKey; options?: GetItemOptions<any> }
>,
): Promise<Array<{ key: StorageItemKey; value: any }>>;
/**
* Return an object containing metadata about the key. Object is stored at `key + "$"`. If value
* is not an object, it returns an empty object.
*
* @example
* await storage.getMeta("local:installDate");
*/
getMeta<T extends Record<string, unknown>>(key: StorageItemKey): Promise<T>;
/**
* Get the metadata of multiple storage items.
*
* @param items List of keys or items to get the metadata of.
* @returns An array containing storage keys and their metadata.
*/
getMetas(
keys: Array<StorageItemKey | WxtStorageItem<any, any>>,
): Promise<Array<{ key: StorageItemKey; meta: any }>>;
/**
* Set a value in storage. Setting a value to `null` or `undefined` is equivalent to calling
* `removeItem`.
*
* @example
* await storage.setItem<number>("local:installDate", Date.now());
*/
setItem<T>(key: StorageItemKey, value: T | null): Promise<void>;
/**
* Set multiple values in storage. If a value is set to `null` or `undefined`, the key is removed.
*
* @example
* await storage.setItem([
* { key: "local:installDate", value: Date.now() },
* { key: "session:someCounter, value: 5 },
* ]);
*/
setItems(
values: Array<
| { key: StorageItemKey; value: any }
| { item: WxtStorageItem<any, any>; value: any }
>,
): Promise<void>;
/**
* Sets metadata properties. If some properties are already set, but are not included in the
* `properties` parameter, they will not be removed.
*
* @example
* await storage.setMeta("local:installDate", { appVersion });
*/
setMeta<T extends Record<string, unknown>>(
key: StorageItemKey,
properties: T | null,
): Promise<void>;
/**
* Set the metadata of multiple storage items.
*
* @param items List of storage keys or items and metadata to set for each.
*/
setMetas(
metas: Array<
| { key: StorageItemKey; meta: Record<string, any> }
| { item: WxtStorageItem<any, any>; meta: Record<string, any> }
>,
): Promise<void>;
/**
* Removes an item from storage.
*
* @example
* await storage.removeItem("local:installDate");
*/
removeItem(key: StorageItemKey, opts?: RemoveItemOptions): Promise<void>;
/**
* Remove a list of keys from storage.
*/
removeItems(
keys: Array<
| StorageItemKey
| WxtStorageItem<any, any>
| { key: StorageItemKey; options?: RemoveItemOptions }
| { item: WxtStorageItem<any, any>; options?: RemoveItemOptions }
>,
): Promise<void>;
/**
* Removes all items from the provided storage area.
*/
clear(base: StorageArea): Promise<void>;
/**
* Remove the entire metadata for a key, or specific properties by name.
*
* @example
* // Remove all metadata properties from the item
* await storage.removeMeta("local:installDate");
*
* // Remove only specific the "v" field
* await storage.removeMeta("local:installDate", "v")
*/
removeMeta(
key: StorageItemKey,
properties?: string | string[],
): Promise<void>;
/**
* Return all the items in storage.
*/
snapshot(
base: StorageArea,
opts?: SnapshotOptions,
): 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 overridden.
*/
restoreSnapshot(base: StorageArea, data: any): Promise<void>;
/**
* Watch for changes to a specific key in storage.
*/
watch<T>(key: StorageItemKey, cb: WatchCallback<T | null>): Unwatch;
/**
* Remove all watch listeners.
*/
unwatch(): void;
/**
* Define a storage item with a default value, type, or versioning.
*
* Read full docs: https://wxt.dev/storage.html#defining-storage-items
*/
defineItem<TValue, TMetadata extends Record<string, unknown> = {}>(
key: StorageItemKey,
): WxtStorageItem<TValue | null, TMetadata>;
defineItem<TValue, TMetadata extends Record<string, unknown> = {}>(
key: StorageItemKey,
options: WxtStorageItemOptions<TValue>,
): WxtStorageItem<TValue, TMetadata>;
}
interface WxtStorageDriver {
getItem<T>(key: string): Promise<T | null>;
getItems(keys: string[]): Promise<{ key: string; value: any }[]>;
setItem<T>(key: string, value: T | null): Promise<void>;
setItems(values: Array<{ key: string; value: any }>): Promise<void>;
removeItem(key: string): Promise<void>;
removeItems(keys: string[]): Promise<void>;
clear(): Promise<void>;
snapshot(): Promise<Record<string, unknown>>;
restoreSnapshot(data: Record<string, unknown>): Promise<void>;
watch<T>(key: string, cb: WatchCallback<T | null>): Unwatch;
unwatch(): void;
}
export interface WxtStorageItem<
TValue,
TMetadata extends Record<string, unknown>,
> {
/**
* The storage key passed when creating the storage item.
*/
key: StorageItemKey;
/**
* @deprecated Renamed to fallback, use it instead.
*/
defaultValue: TValue;
/**
* The value provided by the `fallback` option.
*/
fallback: TValue;
/**
* Get the latest value from storage.
*/
getValue(): Promise<TValue>;
/**
* Get metadata.
*/
getMeta(): Promise<NullablePartial<TMetadata>>;
/**
* Set the value in storage.
*/
setValue(value: TValue): Promise<void>;
/**
* Set metadata properties.
*/
setMeta(properties: NullablePartial<TMetadata>): Promise<void>;
/**
* Remove the value from storage.
*/
removeValue(opts?: RemoveItemOptions): Promise<void>;
/**
* Remove all metadata or certain properties from metadata.
*/
removeMeta(properties?: string[]): Promise<void>;
/**
* 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 type StorageArea = 'local' | 'session' | 'sync' | 'managed';
export type StorageItemKey = `${StorageArea}:${string}`;
export interface GetItemOptions<T> {
/**
* @deprecated Renamed to `fallback`, use it instead.
*/
defaultValue?: T;
/**
* Default value returned when `getItem` would otherwise return `null`.
*/
fallback?: T;
}
export interface RemoveItemOptions {
/**
* Optionally remove metadata when deleting a key.
*
* @default false
*/
removeMeta?: boolean;
}
export interface SnapshotOptions {
/**
* Exclude a list of keys. The storage area prefix should be removed since the snapshot is for a
* specific storage area already.
*/
excludeKeys?: string[];
}
export interface WxtStorageItemOptions<T> {
/**
* @deprecated Renamed to `fallback`, use it instead.
*/
defaultValue?: T;
/**
* Default value returned when `getValue` would otherwise return `null`.
*/
fallback?: T;
/**
* If passed, a value in storage will be initialized immediately after
* defining the storage item. This function returns the value that will be
* saved to storage during the initialization process if a value doesn't
* already exist.
*/
init?: () => T | Promise<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.
*/
version?: number;
/**
* A map of version numbers to the functions used to migrate the data to that version.
*/
migrations?: Record<number, (oldValue: any) => any>;
}
export type StorageAreaChanges = {
[key: string]: chrome.storage.StorageChange;
};
/**
* Same as `Partial`, but includes `| null`. It makes all the properties of an object optional and
* nullable.
*/
type NullablePartial<T> = {
[key in keyof T]+?: T[key] | undefined | null;
};
/**
* Callback called when a value in storage is changed.
*/
export type WatchCallback<T> = (newValue: T, oldValue: T) => void;
/**
* Call to remove a watch listener
*/
export type Unwatch = () => void;
export class MigrationError extends Error {
constructor(
public key: string,
public version: number,
options?: ErrorOptions,
) {
super(`v${version} migration failed for "${key}"`, options);
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"verbatimModuleSyntax": true,
"types": ["chrome"]
},
"exclude": ["node_modules/**", "dist/**"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineProject } from 'vitest/config';
export default defineProject({
test: {
mockReset: true,
restoreMocks: true,
setupFiles: ['vitest.setup.ts'],
},
});
+4
View File
@@ -0,0 +1,4 @@
import { fakeBrowser } from '@webext-core/fake-browser';
import { vi } from 'vitest';
vi.stubGlobal('chrome', fakeBrowser);
+3 -3
View File
@@ -38,10 +38,10 @@ The module can be configured via the `unocss` config:
export default defineConfig({
modules: ['@wxt-dev/unocss'],
unocss: {
// Will only apply unocss for popup/main.ts
entrypoints: ['popup/main.ts'],
// Exclude unocss from running for the background
excludeEntrypoints: ['background'],
},
});
```
Options have JSDocs available in your editor, or you can read them in the source code: [`UnoCSSOptions`](https://github.com/wxt-dev/wxt/blob/main/packages/auto-icons/src/index.ts).
Options have JSDocs available in your editor, or you can read them in the source code: [`UnoCSSOptions`](https://github.com/wxt-dev/wxt/blob/main/packages/unocss/src/index.ts).
+4 -4
View File
@@ -41,11 +41,11 @@
},
"devDependencies": {
"@aklinker1/check": "^1.4.5",
"oxlint": "^0.9.9",
"publint": "^0.2.11",
"typescript": "^5.6.2",
"oxlint": "^0.11.1",
"publint": "^0.2.12",
"typescript": "^5.6.3",
"unbuild": "^2.0.0",
"unocss": "^0.63.3",
"unocss": "^0.64.0",
"wxt": "workspace:*"
},
"dependencies": {
+1 -1
View File
@@ -28,7 +28,7 @@ export default defineWxtModule<UnoCSSOptions>({
}
wxt.hooks.hook('vite:devServer:extendConfig', (config) => {
config.plugins?.push(UnoCSS());
config.plugins?.push(UnoCSS(resolvedOptions.configOrPath));
});
wxt.hooks.hook('vite:build:extendConfig', async (entries, config) => {
+9 -9
View File
@@ -18,19 +18,19 @@
},
"dependencies": {
"@wxt-dev/i18n": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/chrome": "^0.0.269",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/chrome": "^0.0.280",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@wxt-dev/auto-icons": "workspace:*",
"@wxt-dev/unocss": "workspace:*",
"sass": "^1.79.4",
"typescript": "^5.6.2",
"unocss": "^0.63.3",
"vitest": "^2.1.2",
"sass": "^1.80.7",
"typescript": "^5.6.3",
"unocss": "^0.64.0",
"vitest": "^2.1.4",
"vitest-plugin-random-seed": "^1.1.0",
"wxt": "workspace:*"
},
@@ -0,0 +1,76 @@
import 'uno.css';
import './style.css';
export default defineContentScript({
matches: ['https://*.duckduckgo.com/*'],
cssInjectionMode: 'manifest',
async main(ctx) {
const dynamicUI = createIntegratedUi(ctx, {
position: 'inline',
append: 'after',
anchor: 'form[role=search]',
onMount: (container) => {
const app = document.createElement('div');
container.id = 'automount-anchor';
app.classList.add('m-4', 'text-center', 'text-red-500');
app.textContent = i18n.t('prompt_for_name');
container.append(app);
return { container, app };
},
onRemove() {
console.log('dynamicUI removed');
},
});
const autoMountUi = createIntegratedUi(ctx, {
position: 'inline',
append: 'after',
anchor: '#automount-anchor',
onMount: (container) => {
const app = document.createElement('div');
app.id = 'automount-ui';
app.classList.add('m-0', 'text-center', 'text-blue-500');
app.textContent = `Hello, I'm automount UI.`;
container.append(app);
},
onRemove() {
console.log('autoMountUi removed');
},
});
autoMountUi.autoMount({
onStop: () => {
console.log('Auto mount stopped.');
},
});
const stopAutoMountButton = createIntegratedUi(ctx, {
position: 'inline',
append: 'last',
anchor: 'form[role=search]',
onMount: (container) => {
const app = document.createElement('button');
container.classList.add('flex', 'flex-justify-center');
app.classList.add('mt-4', 'p-2');
app.textContent = 'Stop auto-mount';
app.onclick = (e) => {
e.preventDefault();
autoMountUi.remove();
};
container.append(app);
return { container, app };
},
});
stopAutoMountButton.mount();
setInterval(() => {
if (dynamicUI.mounted) {
dynamicUI.remove();
} else {
dynamicUI.mount();
}
}, 2000);
},
});
@@ -0,0 +1,3 @@
:root {
color-scheme: dark;
}
@@ -14,12 +14,13 @@ export default defineBackground({
console.log(useAppConfig());
// @ts-expect-error: should only accept entrypoints or public assets
browser.runtime.getURL('/');
browser.runtime.getURL('/background.js');
browser.runtime.getURL('/icons/128.png');
browser.runtime.getURL('/example.html#hash');
browser.runtime.getURL('/example.html?query=param');
// @ts-expect-error: should only accept entrypoints or public assets
browser.runtime.getURL('/unknown');
// @ts-expect-error: should only allow hashes/query params on HTML files
browser.runtime.getURL('/icon-128.png?query=param');
+1 -1
View File
@@ -25,7 +25,7 @@ export default defineConfig({
},
example: {
a: 'a',
// @ts-expect-error: c is not defined, this should error out
// @ts-expect-error: c is not defined, this should be a type error, but it should show up in the module
c: 'c',
},
unocss: {
+242 -1
View File
@@ -1,5 +1,246 @@
# Changelog
## v0.19.26
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.25...wxt-v0.19.26)
### 🩹 Fixes
- **context:** Deduplicate `wxt:content-script-started` ([#1364](https://github.com/wxt-dev/wxt/pull/1364))
### ❤️ Contributors
- Deniz Uğur ([@DenizUgur](http://github.com/DenizUgur))
## v0.19.25
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.24...wxt-v0.19.25)
### 🩹 Fixes
- Drop support for vite 6.0.9+ until websocket fix is finished ([8e2badc](https://github.com/wxt-dev/wxt/commit/8e2badc))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
## v0.19.24
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.23...wxt-v0.19.24)
### 🩹 Fixes
- **init:** Remove "experimental" from bun package manager ([b150a52](https://github.com/wxt-dev/wxt/commit/b150a52))
- **web-ext:** Correctly pass `browserConsole` option to open devtools in Firefox ([#1308](https://github.com/wxt-dev/wxt/pull/1308))
- `autoMount` not working if anchor is already present ([#1350](https://github.com/wxt-dev/wxt/pull/1350))
### 🏡 Chore
- Fix typo in CLI docs ([028c601](https://github.com/wxt-dev/wxt/commit/028c601))
### ❤️ Contributors
- 1natsu ([@1natsu172](http://github.com/1natsu172))
- Paulo Pinto ([@psrpinto](https://github.com/psrpinto))
- Aaron ([@aklinker1](http://github.com/aklinker1))
## v0.19.23
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.22...wxt-v0.19.23)
### 🚀 Enhancements
- **hooks:** Move `entrypoints:resolved` before debug logs and add `entrypoints:found` ([#1292](https://github.com/wxt-dev/wxt/pull/1292))
### 🩹 Fixes
- Allow runtime registered content scripts to not have `matches` ([#1306](https://github.com/wxt-dev/wxt/pull/1306))
- Properly close readline instance on close ([#1278](https://github.com/wxt-dev/wxt/pull/1278))
### 📖 Documentation
- Sync READMEs ([27298b7](https://github.com/wxt-dev/wxt/commit/27298b7))
### 🏡 Chore
- Simplify keyboard shortcuts ([#1284](https://github.com/wxt-dev/wxt/pull/1284))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
- Abhigyan Trips ([@abhigyantrips](http://github.com/abhigyantrips))
## v0.19.22
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.21...wxt-v0.19.22)
### 🩹 Fixes
- Exclude entire `import.meta.env` object from content script output ([#1267](https://github.com/wxt-dev/wxt/pull/1267))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
## v0.19.21
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.20...wxt-v0.19.21)
### 🩹 Fixes
- Exclude skipped entrypoints from manifest ([#1265](https://github.com/wxt-dev/wxt/pull/1265))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
## v0.19.20
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.19...wxt-v0.19.20)
### 🚀 Enhancements
- `autoMount` content script UIs ([#1210](https://github.com/wxt-dev/wxt/pull/1210))
### 🩹 Fixes
- Only show reopen browser shortcut when it can be done ([#1263](https://github.com/wxt-dev/wxt/pull/1263))
- Import entrypoint improvements ([#1207](https://github.com/wxt-dev/wxt/pull/1207))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
- 1natsu ([@1natsu172](http://github.com/1natsu172))
## v0.19.19
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.18...wxt-v0.19.19)
### 🚀 Enhancements
- Keyboard shortcut to reopen the browser without restarting the dev command ([#1211](https://github.com/wxt-dev/wxt/pull/1211))
### 🩹 Fixes
- Prevent changing dev server port when reloading config ([#1241](https://github.com/wxt-dev/wxt/pull/1241))
- Ensure content scripts are registered immediately in dev mode ([#1253](https://github.com/wxt-dev/wxt/pull/1253))
- Exclude skipped entrypoints from Firefox sources zip ([#1238](https://github.com/wxt-dev/wxt/pull/1238))
### ❤️ Contributors
- Nishu ([@nishu-murmu](http://github.com/nishu-murmu))
- Aaron ([@aklinker1](http://github.com/aklinker1))
## v0.19.18
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.17...wxt-v0.19.18)
### 🩹 Fixes
- Correct `dev.reloadCommand` restriction to consider only commands with suggested keys ([#1226](https://github.com/wxt-dev/wxt/pull/1226))
### 🌊 Types
- Add overloads to `ContentScriptContext#addEventListener` for different targets ([#1245](https://github.com/wxt-dev/wxt/pull/1245))
### 🏡 Chore
- Refactor `findEntrypoints` to return all entrypoints with `skipped` set properly ([#1244](https://github.com/wxt-dev/wxt/pull/1244))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
- Jaguar Zhou ([@aiktb](http://github.com/aiktb))
## v0.19.17
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.16...wxt-v0.19.17)
### 🚀 Enhancements
- New `server:created`, `server:started`, and `server:closed` hooks ([#1179](https://github.com/wxt-dev/wxt/pull/1179))
### 🩹 Fixes
- Re-initialize WXT modules correctly during development ([#1176](https://github.com/wxt-dev/wxt/pull/1176))
- ESLint config being generated when ESLint is not installed. ([#1198](https://github.com/wxt-dev/wxt/pull/1198))
- Update `vite` dependency range to support v6 ([#1215](https://github.com/wxt-dev/wxt/pull/1215))
- Automatically convert MV3 `content_security_policy` to MV2 ([#1168](https://github.com/wxt-dev/wxt/pull/1168))
- Correctly remove child elements with integrated UI remove ([#1219](https://github.com/wxt-dev/wxt/pull/1219))
- Make content script `matches` optional ([#1220](https://github.com/wxt-dev/wxt/pull/1220))
### 📖 Documentation
- Fix analyze typo in type ([#1187](https://github.com/wxt-dev/wxt/pull/1187))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
- 1natsu ([@1natsu172](http://github.com/1natsu172))
- Nishu ([@nishu-murmu](https://github.com/nishu-murmu))
- Florian Metz ([@Timeraa](http://github.com/Timeraa))
## v0.19.16
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.15...wxt-v0.19.16)
### 🚀 Enhancements
- **hooks:** Add new `config:resolved` hook ([#1177](https://github.com/wxt-dev/wxt/pull/1177))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
## v0.19.15
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.14...wxt-v0.19.15)
### 🚀 Enhancements
- Extract `wxt/storage` to its own package, `@wxt-dev/storage` ([#1129](https://github.com/wxt-dev/wxt/pull/1129))
### 🩹 Fixes
- Add "/" to `PublicPath` and `browser.runtime.getURL` ([#1171](https://github.com/wxt-dev/wxt/pull/1171))
- Add extension ID to event used to invalidate `ContentScriptContext` ([#1175](https://github.com/wxt-dev/wxt/pull/1175))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
- Nishu ([@nishu-murmu](http://github.com/nishu-murmu))
## v0.19.14
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.13...wxt-v0.19.14)
### 🚀 Enhancements
- **storage:** Support storage items in batch functions ([#990](https://github.com/wxt-dev/wxt/pull/990))
- Automatically disable 'Show warning about Self-XSS when pasing code' in new chrome ([#1159](https://github.com/wxt-dev/wxt/pull/1159))
### 🩹 Fixes
- Throw when config file does not exist ([#1156](https://github.com/wxt-dev/wxt/pull/1156))
### 📖 Documentation
- Cleanup typos and broken links ([bb5ea34](https://github.com/wxt-dev/wxt/commit/bb5ea34))
- Fix typo in `popup` and `options` EntrypointOptions ([#1121](https://github.com/wxt-dev/wxt/pull/1121))
### 🏡 Chore
- **deps:** Upgrade all non-major dependencies ([#1164](https://github.com/wxt-dev/wxt/pull/1164))
- **deps:** Bump dev and non-breaking major dependencies ([#1167](https://github.com/wxt-dev/wxt/pull/1167))
### ❤️ Contributors
- Aaron ([@aklinker1](http://github.com/aklinker1))
- Rxliuli ([@rxliuli](http://github.com/rxliuli))
- Kongmoumou ([@kongmoumou](http://github.com/kongmoumou))
- Florian Metz ([@Timeraa](http://github.com/Timeraa))
- Bread Grocery <breadgrocery@gmail.com>
## v0.19.13
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.12...wxt-v0.19.13)
@@ -2578,4 +2819,4 @@ Initial release of WXT. Full support for production builds and initial toolkit f
### 🤖 CI
- Create validation workflow ([#12](https://github.com/wxt-dev/wxt/pull/12))
- Create release workflow ([#13](https://github.com/wxt-dev/wxt/pull/13))
- Create release workflow ([#13](https://github.com/wxt-dev/wxt/pull/13))
+6
View File
@@ -76,6 +76,12 @@ Or see the [installation guide](https://wxt.dev/guide/installation.html) to get
- 📏 Bundle analysis
- ⬇️ Download and bundle remote URL imports
## Sponsors
WXT is a [MIT-licensed](https://github.com/wxt-dev/wxt/blob/main/LICENSE) open source project with its ongoing development made possible entirely by the support of these awesome backers. If you'd like to join them, please consider [sponsoring WXT's development](https://github.com/sponsors/wxt-dev).
<a href="https://github.com/sponsors/wxt-dev"><img alt="WXT Sponsors" src="https://raw.githubusercontent.com/wxt-dev/static/refs/heads/main/sponsorkit/sponsors.svg"></a>
## Contributors
<!-- automd:contributors author="aklinker1" license="MIT" github="wxt-dev/wxt" -->
@@ -8,6 +8,7 @@ exports[`Auto Imports > eslintrc > "enabled: 8" should output a JSON config file
"ContentScriptContext": true,
"InvalidMatchPattern": true,
"MatchPattern": true,
"MigrationError": true,
"browser": true,
"createIframeUi": true,
"createIntegratedUi": true,
@@ -34,6 +35,7 @@ const globals = {
"ContentScriptContext": true,
"InvalidMatchPattern": true,
"MatchPattern": true,
"MigrationError": true,
"browser": true,
"createIframeUi": true,
"createIntegratedUi": true,
@@ -68,6 +70,7 @@ exports[`Auto Imports > eslintrc > "enabled: true" should output a JSON config f
"ContentScriptContext": true,
"InvalidMatchPattern": true,
"MatchPattern": true,
"MigrationError": true,
"browser": true,
"createIframeUi": true,
"createIntegratedUi": true,
@@ -95,6 +98,7 @@ exports[`Auto Imports > eslintrc > should allow customizing the output 1`] = `
"ContentScriptContext": "readonly",
"InvalidMatchPattern": "readonly",
"MatchPattern": "readonly",
"MigrationError": "readonly",
"browser": "readonly",
"createIframeUi": "readonly",
"createIntegratedUi": "readonly",
@@ -20,6 +20,7 @@ describe('Auto Imports', () => {
const ContentScriptContext: typeof import('wxt/client')['ContentScriptContext']
const InvalidMatchPattern: typeof import('wxt/sandbox')['InvalidMatchPattern']
const MatchPattern: typeof import('wxt/sandbox')['MatchPattern']
const MigrationError: typeof import('wxt/storage')['MigrationError']
const browser: typeof import('wxt/browser')['browser']
const createIframeUi: typeof import('wxt/client')['createIframeUi']
const createIntegratedUi: typeof import('wxt/client')['createIntegratedUi']
@@ -151,6 +152,40 @@ describe('Auto Imports', () => {
).toMatchSnapshot();
});
it('"enabled: false" should NOT output an ESlint config file', async () => {
const project = new TestProject();
project.addFile('entrypoints/popup.html', `<html></html>`);
await project.prepare({
imports: {
eslintrc: {
enabled: false,
},
},
});
expect(await project.fileExists('.wxt/eslint-auto-imports.mjs')).toBe(
false,
);
expect(await project.fileExists('.wxt/eslintrc-auto-import.json')).toBe(
false,
);
});
it('should NOT output an ESlint config file by default', async () => {
const project = new TestProject();
project.addFile('entrypoints/popup.html', `<html></html>`);
await project.prepare();
expect(await project.fileExists('.wxt/eslint-auto-imports.mjs')).toBe(
false,
);
expect(await project.fileExists('.wxt/eslintrc-auto-import.json')).toBe(
false,
);
});
it('should allow customizing the output', async () => {
const project = new TestProject();
project.addFile('entrypoints/popup.html', `<html></html>`);
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { TestProject } from '../utils';
describe('Dev Mode', () => {
it('should not change ports when restarting the server', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/background.ts',
'export default defineBackground(() => {})',
);
const server = await project.startServer({
runner: {
disabled: true,
},
});
const initialPort = server.port;
await server.restart();
const finalPort = server.port;
await server.stop();
expect(finalPort).toBe(initialPort);
});
});
+32 -1
View File
@@ -4,12 +4,14 @@ import { WxtHooks } from '../../src/types';
const hooks: WxtHooks = {
ready: vi.fn(),
'config:resolved': vi.fn(),
'prepare:types': vi.fn(),
'prepare:publicPaths': vi.fn(),
'build:before': vi.fn(),
'build:done': vi.fn(),
'build:manifestGenerated': vi.fn(),
'build:publicAssets': vi.fn(),
'entrypoints:found': vi.fn(),
'entrypoints:resolved': vi.fn(),
'entrypoints:grouped': vi.fn(),
'vite:build:extendConfig': vi.fn(),
@@ -20,6 +22,9 @@ const hooks: WxtHooks = {
'zip:sources:start': vi.fn(),
'zip:sources:done': vi.fn(),
'zip:done': vi.fn(),
'server:created': vi.fn(),
'server:started': vi.fn(),
'server:closed': vi.fn(),
};
function expectHooksToBeCalled(
@@ -49,12 +54,14 @@ describe('Hooks', () => {
expectHooksToBeCalled({
ready: true,
'config:resolved': true,
'prepare:types': true,
'prepare:publicPaths': true,
'build:before': false,
'build:done': false,
'build:publicAssets': false,
'build:manifestGenerated': false,
'entrypoints:found': true,
'entrypoints:grouped': false,
'entrypoints:resolved': true,
'vite:build:extendConfig': false,
@@ -65,6 +72,9 @@ describe('Hooks', () => {
'zip:sources:start': false,
'zip:sources:done': false,
'zip:done': false,
'server:created': false,
'server:started': false,
'server:closed': false,
});
});
@@ -76,12 +86,14 @@ describe('Hooks', () => {
expectHooksToBeCalled({
ready: true,
'config:resolved': true,
'prepare:types': true,
'prepare:publicPaths': true,
'build:before': true,
'build:done': true,
'build:publicAssets': true,
'build:manifestGenerated': true,
'entrypoints:found': true,
'entrypoints:grouped': true,
'entrypoints:resolved': true,
'vite:build:extendConfig': 1,
@@ -92,6 +104,9 @@ describe('Hooks', () => {
'zip:sources:start': false,
'zip:sources:done': false,
'zip:done': false,
'server:created': false,
'server:started': false,
'server:closed': false,
});
});
@@ -103,12 +118,14 @@ describe('Hooks', () => {
expectHooksToBeCalled({
ready: true,
'config:resolved': true,
'prepare:types': true,
'prepare:publicPaths': true,
'build:before': true,
'build:done': true,
'build:publicAssets': true,
'build:manifestGenerated': true,
'entrypoints:found': true,
'entrypoints:grouped': true,
'entrypoints:resolved': true,
'vite:build:extendConfig': 1,
@@ -119,6 +136,9 @@ describe('Hooks', () => {
'zip:sources:start': false,
'zip:sources:done': false,
'zip:done': true,
'server:created': false,
'server:started': false,
'server:closed': false,
});
});
@@ -130,14 +150,16 @@ describe('Hooks', () => {
expectHooksToBeCalled({
ready: true,
'config:resolved': true,
'prepare:types': true,
'prepare:publicPaths': true,
'build:before': true,
'build:done': true,
'build:publicAssets': true,
'build:manifestGenerated': true,
'entrypoints:found': 2,
'entrypoints:grouped': true,
'entrypoints:resolved': true,
'entrypoints:resolved': 2,
'vite:build:extendConfig': 1,
'vite:devServer:extendConfig': false,
'zip:start': true,
@@ -146,6 +168,9 @@ describe('Hooks', () => {
'zip:sources:start': true,
'zip:sources:done': true,
'zip:done': true,
'server:created': false,
'server:started': false,
'server:closed': false,
});
});
@@ -159,16 +184,19 @@ describe('Hooks', () => {
disabled: true,
},
});
expect(hooks['server:closed']).not.toBeCalled();
await server.stop();
expectHooksToBeCalled({
ready: true,
'config:resolved': true,
'prepare:types': true,
'prepare:publicPaths': true,
'build:before': true,
'build:done': true,
'build:publicAssets': true,
'build:manifestGenerated': true,
'entrypoints:found': true,
'entrypoints:grouped': true,
'entrypoints:resolved': true,
'vite:build:extendConfig': 2,
@@ -179,6 +207,9 @@ describe('Hooks', () => {
'zip:sources:start': false,
'zip:sources:done': false,
'zip:done': false,
'server:created': 1,
'server:started': 1,
'server:closed': 1,
});
});
});
+1 -1
View File
@@ -6,7 +6,7 @@ import { normalizePath } from '../../src/core/utils/paths';
describe('Module Helpers', () => {
describe('options', () => {
it('should recieve the options defined in wxt.config.ts based on the configKey field', async () => {
it('should receive the options defined in wxt.config.ts based on the configKey field', async () => {
const options = { key: '123' };
const reportOptions = vi.fn();
vi.stubGlobal('reportOptions', reportOptions);
@@ -48,6 +48,7 @@ describe('TypeScript Project', () => {
declare module "wxt/browser" {
export type PublicPath =
| "/"
| "/options.html"
| "/popup.html"
| "/sandbox.html"
@@ -157,4 +157,18 @@ describe('User Config', () => {
await project.fileExists('.output/test-chrome-mv3-development-dev-build'),
).toBe(true);
});
it('should throw error when config file not exist', async () => {
const project = new TestProject();
project.addFile(
'src/entrypoints/background.ts',
`export default defineBackground(
() => console.log('Hello background'),
);`,
);
await expect(
project.build({ configFile: 'foo.config.ts' }),
).rejects.toThrowError(/not found/);
});
});
+36
View File
@@ -181,6 +181,42 @@ describe('Zipping', () => {
);
});
it('should exclude skipped entrypoints from respective browser sources zip', async () => {
const project = new TestProject({
name: 'test',
version: '1.0.0',
});
project.addFile(
'entrypoints/not-firefox.content.ts',
`export default defineContentScript({
matches: ['*://*/*'],
exclude: ['firefox'],
main() {},
});`,
);
project.addFile(
'entrypoints/all.content.ts',
`export default defineContentScript({
matches: ['*://*/*'],
main(ctx) {},
});
`,
);
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',
});
await extract(sourcesZip, { dir: unzipDir });
expect(
await project.fileExists(unzipDir, 'entrypoints/not-firefox.content.ts'),
).toBe(false);
expect(
await project.fileExists(unzipDir, 'entrypoints/all.content.ts'),
).toBe(true);
});
it.each(['firefox', 'opera'])(
'should create sources zip for "%s" browser when sourcesZip is undefined',
async (browser) => {
+19 -18
View File
@@ -1,7 +1,7 @@
{
"name": "wxt",
"type": "module",
"version": "0.19.13",
"version": "0.19.26",
"description": "Next gen framework for developing web extensions",
"repository": {
"type": "git",
@@ -81,26 +81,27 @@
"prepack": "pnpm build"
},
"dependencies": {
"@1natsu/wait-element": "^4.1.2",
"@aklinker1/rollup-plugin-visualizer": "5.12.0",
"@types/chrome": "^0.0.269",
"@types/webextension-polyfill": "^0.10.7",
"@types/chrome": "^0.0.280",
"@types/webextension-polyfill": "^0.12.1",
"@webext-core/fake-browser": "^1.3.1",
"@webext-core/isolated-element": "^1.1.2",
"@webext-core/match-patterns": "^1.0.3",
"@wxt-dev/storage": "workspace:^1.0.0",
"async-mutex": "^0.5.0",
"c12": "^1.11.2",
"cac": "^6.7.14",
"chokidar": "^3.6.0",
"ci-info": "^4.0.0",
"ci-info": "^4.1.0",
"consola": "^3.2.3",
"defu": "^6.1.4",
"dequal": "^2.0.3",
"dotenv": "^16.4.5",
"esbuild": "^0.21.5",
"fast-glob": "^3.3.2",
"filesize": "^10.1.6",
"fs-extra": "^11.2.0",
"get-port": "^7.1.0",
"get-port-please": "^3.1.2",
"giget": "^1.2.3",
"hookable": "^5.5.3",
"is-wsl": "^3.1.0",
@@ -110,40 +111,40 @@
"linkedom": "^0.18.5",
"magicast": "^0.3.5",
"minimatch": "^10.0.1",
"nano-spawn": "^0.1.0",
"nano-spawn": "^0.2.0",
"normalize-path": "^3.0.0",
"nypm": "^0.3.12",
"ohash": "^1.1.4",
"open": "^10.1.0",
"ora": "^8.1.0",
"ora": "^8.1.1",
"perfect-debounce": "^1.0.0",
"picocolors": "^1.1.0",
"picocolors": "^1.1.1",
"prompts": "^2.4.2",
"publish-browser-extension": "^2.2.2",
"scule": "^1.3.0",
"unimport": "^3.13.1",
"vite": "^5.4.8",
"vite-node": "^2.1.2",
"vite": "^5.0.0 || <=6.0.8",
"vite-node": "^2.1.4",
"web-ext-run": "^0.2.1",
"webextension-polyfill": "^0.12.0"
},
"devDependencies": {
"@aklinker1/check": "^1.4.5",
"@faker-js/faker": "^8.4.1",
"@faker-js/faker": "^9.2.0",
"@types/fs-extra": "^11.0.4",
"@types/lodash.merge": "^4.6.9",
"@types/node": "^20.16.10",
"@types/node": "^20.17.6",
"@types/normalize-path": "^3.0.2",
"@types/prompts": "^2.4.9",
"extract-zip": "^2.0.1",
"happy-dom": "^14.12.3",
"happy-dom": "^15.11.4",
"lodash.merge": "^4.6.2",
"oxlint": "^0.9.9",
"publint": "^0.2.11",
"oxlint": "^0.11.1",
"publint": "^0.2.12",
"tsx": "4.15.7",
"typescript": "^5.6.2",
"typescript": "^5.6.3",
"unbuild": "^2.0.0",
"vitest": "^2.1.2",
"vitest": "^2.1.4",
"vitest-plugin-random-seed": "^1.1.0"
},
"peerDependenciesMeta": {
+5 -5
View File
@@ -15,7 +15,7 @@ describe('Module Utilities', () => {
wxt.config.vite = () => Promise.resolve(userConfig);
addViteConfig(wxt, () => moduleConfig);
await wxt.hooks.callHook('ready', wxt);
await wxt.hooks.callHook('config:resolved', wxt);
const actual = await wxt.config.vite(wxt.config.env);
expect(actual).toEqual(expected);
@@ -31,7 +31,7 @@ describe('Module Utilities', () => {
wxt.config.vite = () => userConfig;
addViteConfig(wxt, () => moduleConfig);
await wxt.hooks.callHook('ready', wxt);
await wxt.hooks.callHook('config:resolved', wxt);
const actual = await wxt.config.vite(wxt.config.env);
expect(actual).toEqual(expected);
@@ -44,7 +44,7 @@ describe('Module Utilities', () => {
const wxt = fakeWxt({ hooks: createHooks() });
addImportPreset(wxt, preset);
await wxt.hooks.callHook('ready', wxt);
await wxt.hooks.callHook('config:resolved', wxt);
expect(wxt.config.imports && wxt.config.imports.presets).toContain(
preset,
@@ -63,7 +63,7 @@ describe('Module Utilities', () => {
});
addImportPreset(wxt, preset);
await wxt.hooks.callHook('ready', wxt);
await wxt.hooks.callHook('config:resolved', wxt);
expect(wxt.config.imports && wxt.config.imports.presets).toHaveLength(2);
});
@@ -78,7 +78,7 @@ describe('Module Utilities', () => {
});
addImportPreset(wxt, preset);
await wxt.hooks.callHook('ready', wxt);
await wxt.hooks.callHook('config:resolved', wxt);
expect(wxt.config.imports).toBe(false);
});
-1
View File
@@ -80,7 +80,6 @@ export type {
Theme,
TopSites,
Types,
Urlbar,
UserScripts,
WebNavigation,
WebRequest,
+4 -4
View File
@@ -18,7 +18,7 @@ export default defineWxtModule({
let unimport: Unimport;
// Add user module imports to config
wxt.hooks.hook('ready', () => {
wxt.hooks.hook('config:resolved', () => {
const addModuleImports = (module: WxtModule<any>) => {
if (!module.imports) return;
@@ -30,10 +30,10 @@ export default defineWxtModule({
wxt.config.userModules.forEach(addModuleImports);
});
// Create unimport instance AFTER "ready" so any modifications to the
// config inside "ready" are applied.
// Create unimport instance AFTER "config:resolved" so any modifications to the
// config inside "config:resolved" are applied.
wxt.hooks.afterEach((event) => {
if (event.name === 'ready') {
if (event.name === 'config:resolved') {
unimport = createUnimport(options);
}
});
@@ -3,7 +3,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContentScriptContext } from '..';
import { fakeBrowser } from '@webext-core/fake-browser';
import { sleep } from '../../../core/utils/time';
/**
* When dispatching events on document/window/etc, they are fired on the next
* tick of the event loop. So waiting a timeout of 0 will ensure they've been
* fired.
*/
function waitForEventsToFire() {
return new Promise((res) => setTimeout(res));
}
describe('Content Script Context', () => {
beforeEach(() => {
@@ -31,11 +39,11 @@ describe('Content Script Context', () => {
ctx.onInvalidated(onInvalidated);
// Wait for events to run before next tick next tick
await sleep(0);
await waitForEventsToFire();
// Create a new context after first is initialized, and wait for it to initialize
new ContentScriptContext(name);
await sleep(0);
await waitForEventsToFire();
expect(onInvalidated).toBeCalled();
expect(ctx.isValid).toBe(false);
@@ -47,13 +55,35 @@ describe('Content Script Context', () => {
ctx.onInvalidated(onInvalidated);
// Wait for events to run before next tick next tick
await sleep(0);
await waitForEventsToFire();
// Create a new context after first is initialized, and wait for it to initialize
new ContentScriptContext('test2');
await sleep(0);
await waitForEventsToFire();
expect(onInvalidated).not.toBeCalled();
expect(ctx.isValid).toBe(true);
});
describe('addEventListener', () => {
const context = new ContentScriptContext('test');
it('should infer types correctly for the window target', () => {
context.addEventListener(window, 'DOMContentLoaded', (_) => {});
context.addEventListener(window, 'orientationchange', (_) => {});
context.addEventListener(window, 'wxt:locationchange', (_) => {});
// @ts-expect-error
context.addEventListener(window, 'visibilitychange', (_) => {});
});
it('should infer types correctly for the document target', () => {
context.addEventListener(document, 'visibilitychange', (_) => {});
context.addEventListener(document, 'readystatechange', (_) => {});
});
it('should infer types correctly for HTML element targets', () => {
const button = document.createElement('button');
context.addEventListener(button, 'click', (_) => {});
context.addEventListener(button, 'mouseover', (_) => {});
});
});
});
@@ -35,11 +35,14 @@ import { createLocationWatcher } from './location-watcher';
* ```
*/
export class ContentScriptContext implements AbortController {
private static SCRIPT_STARTED_MESSAGE_TYPE = 'wxt:content-script-started';
private static SCRIPT_STARTED_MESSAGE_TYPE = getUniqueEventName(
'wxt:content-script-started',
);
private isTopFrame = window.self === window.top;
private abortController: AbortController;
private locationWatcher = createLocationWatcher(this);
private receivedMessageIds = new Set<string>();
constructor(
private readonly contentScriptName: string,
@@ -173,19 +176,32 @@ export class ContentScriptContext implements AbortController {
* ctx.addEventListener(document, "visibilitychange", () => {
* // ...
* });
* ctx.addEventListener(document, "wxt:locationchange", () => {
* ctx.addEventListener(window, "wxt:locationchange", () => {
* // ...
* });
*/
addEventListener<
TTarget extends EventTarget,
TType extends keyof WxtContentScriptEventMap,
>(
target: TTarget,
addEventListener<TType extends keyof WxtWindowEventMap>(
target: Window,
type: TType,
handler: (event: WxtContentScriptEventMap[TType]) => void,
handler: (event: WxtWindowEventMap[TType]) => void,
options?: AddEventListenerOptions,
) {
): void;
addEventListener<TType extends keyof DocumentEventMap>(
target: Document,
type: keyof DocumentEventMap,
handler: (event: DocumentEventMap[TType]) => void,
options?: AddEventListenerOptions,
): void;
addEventListener<TTarget extends EventTarget>(
target: TTarget,
...params: Parameters<TTarget['addEventListener']>
): void;
addEventListener(
target: EventTarget,
type: string,
handler: (event: Event) => void,
options?: AddEventListenerOptions,
): void {
if (type === 'wxt:locationchange') {
// Start the location watcher when adding the event for the first time
if (this.isValid) this.locationWatcher.run();
@@ -193,7 +209,6 @@ export class ContentScriptContext implements AbortController {
target.addEventListener?.(
type.startsWith('wxt:') ? getUniqueEventName(type) : type,
// @ts-expect-error: Event don't match, but that's OK, EventTarget doesn't allow custom types in the callback
handler,
{
...options,
@@ -219,19 +234,28 @@ export class ContentScriptContext implements AbortController {
{
type: ContentScriptContext.SCRIPT_STARTED_MESSAGE_TYPE,
contentScriptName: this.contentScriptName,
messageId: Math.random().toString(36).slice(2),
},
'*',
);
}
verifyScriptStartedEvent(event: MessageEvent) {
const isScriptStartedEvent =
event.data?.type === ContentScriptContext.SCRIPT_STARTED_MESSAGE_TYPE;
const isSameContentScript =
event.data?.contentScriptName === this.contentScriptName;
const isNotDuplicate = !this.receivedMessageIds.has(event.data?.messageId);
return isScriptStartedEvent && isSameContentScript && isNotDuplicate;
}
listenForNewerScripts(options?: { ignoreFirstEvent?: boolean }) {
let isFirst = true;
const cb = (event: MessageEvent) => {
if (
event.data?.type === ContentScriptContext.SCRIPT_STARTED_MESSAGE_TYPE &&
event.data?.contentScriptName === this.contentScriptName
) {
if (this.verifyScriptStartedEvent(event)) {
this.receivedMessageIds.add(event.data.messageId);
const wasFirst = isFirst;
isFirst = false;
if (wasFirst && options?.ignoreFirstEvent) return;
@@ -245,6 +269,6 @@ export class ContentScriptContext implements AbortController {
}
}
interface WxtContentScriptEventMap extends WindowEventMap {
export interface WxtWindowEventMap extends WindowEventMap {
'wxt:locationchange': WxtLocationChangeEvent;
}
@@ -15,12 +15,5 @@ export class WxtLocationChangeEvent extends Event {
* Returns an event name unique to the extension and content script that's running.
*/
export function getUniqueEventName(eventName: string): string {
// During the build process, import.meta.env is not defined when importing
// entrypoints to get their metadata.
const entrypointName =
typeof import.meta.env === 'undefined'
? 'build'
: import.meta.env.ENTRYPOINT;
return `${browser?.runtime?.id}:${entrypointName}:${eventName}`;
return `${browser?.runtime?.id}:${import.meta.env.ENTRYPOINT}:${eventName}`;
}
@@ -1,12 +1,43 @@
/** @vitest-environment happy-dom */
import { describe, it, beforeEach, vi, expect } from 'vitest';
import { createIntegratedUi, createIframeUi, createShadowRootUi } from '..';
import { describe, it, beforeEach, afterEach, vi, expect } from 'vitest';
import {
createIntegratedUi,
createIframeUi,
createShadowRootUi,
ContentScriptUi,
} from '..';
import { ContentScriptContext } from '../../content-script-context';
/**
* Util for floating promise.
*/
async function runMicrotasks() {
return await new Promise((resolve) => setTimeout(resolve, 0));
}
function appendTestApp(container: HTMLElement) {
container.innerHTML = '<app>Hello world</app>';
}
function appendTestElement({
tagName = 'div',
id,
}: {
tagName?: string;
id: string;
}) {
const parent = document.querySelector('#parent');
if (!parent) {
throw Error(
'Parent element not found. Please check the testing environment DOM',
);
}
const element = document.createElement(tagName);
element.id = id;
parent.append(element);
return element;
}
const fetch = vi.fn();
describe('Content Script UIs', () => {
@@ -480,4 +511,242 @@ describe('Content Script UIs', () => {
});
});
});
/**
* Need call runMicrotasks after floating-promise and append/remove dom
*/
describe('auto mount', () => {
const DYNAMIC_CHILD_ID = 'dynamic-child';
let ui: ContentScriptUi<any>;
beforeEach(async () => {
ui?.remove();
await runMicrotasks();
});
afterEach(async () => {
ui?.remove();
await runMicrotasks();
});
describe.each([
{
name: 'integrated',
createUiFunction: createIntegratedUi,
uiSelector: 'div[data-wxt-integrated]',
},
{
name: 'iframe',
createUiFunction: createIframeUi,
uiSelector: 'div[data-wxt-iframe]',
},
{
name: 'shadow-root',
createUiFunction: createShadowRootUi,
uiSelector: 'test-component[data-wxt-shadow-root]',
},
] as const)(
'built-in UI type: $name',
({ name, createUiFunction, uiSelector }) => {
it('should mount if an anchor already exists at the initialization', async () => {
const onMount = vi.fn(appendTestApp);
ui = await createUiFunction(ctx, {
position: 'inline',
onMount,
anchor: `#parent > #${DYNAMIC_CHILD_ID}`,
page: name === 'iframe' ? '/page.html' : undefined,
name: 'test-component',
});
appendTestElement({ id: DYNAMIC_CHILD_ID });
ui.autoMount();
await runMicrotasks();
await expect
.poll(() => document.querySelector(uiSelector))
.not.toBeNull();
});
it('should mount when an anchor is dynamically added and unmount when an anchor is removed', async () => {
const onMount = vi.fn(appendTestApp);
const onRemove = vi.fn();
ui = await createUiFunction(ctx, {
position: 'inline',
onMount,
onRemove,
anchor: `#parent > #${DYNAMIC_CHILD_ID}`,
page: name === 'iframe' ? '/page.html' : undefined,
name: 'test-component',
});
let dynamicEl;
ui.autoMount();
await runMicrotasks();
for (let index = 0; index < 3; index++) {
await expect
.poll(() => document.querySelector(uiSelector))
.toBeNull();
dynamicEl = appendTestElement({ id: DYNAMIC_CHILD_ID });
await runMicrotasks();
await expect
.poll(() => document.querySelector(uiSelector))
.not.toBeNull();
dynamicEl.remove();
await runMicrotasks();
}
expect(onMount).toHaveBeenCalledTimes(3);
expect(onRemove).toHaveBeenCalledTimes(3);
});
describe('options', () => {
it('should auto-mount only once mount and remove when the `once` option is true', async () => {
const onMount = vi.fn(appendTestApp);
const onRemove = vi.fn();
ui = await createUiFunction(ctx, {
position: 'inline',
onMount,
onRemove,
anchor: `#parent > #${DYNAMIC_CHILD_ID}`,
page: name === 'iframe' ? '/page.html' : undefined,
name: 'test-component',
});
let dynamicEl;
ui.autoMount({ once: true });
await runMicrotasks();
await expect
.poll(() => document.querySelector(uiSelector))
.toBeNull();
dynamicEl = appendTestElement({ id: DYNAMIC_CHILD_ID });
await runMicrotasks();
await expect
.poll(() => document.querySelector(uiSelector))
.not.toBeNull();
dynamicEl.remove();
await runMicrotasks();
expect(onMount).toHaveBeenCalledTimes(1);
expect(onRemove).toHaveBeenCalledTimes(1);
// re-append after once cycle
dynamicEl = appendTestElement({ id: DYNAMIC_CHILD_ID });
await runMicrotasks();
// expect stop automount
await expect
.poll(() => document.querySelector(uiSelector))
.toBeNull();
expect(onMount).toHaveBeenCalledTimes(1);
expect(onRemove).toHaveBeenCalledTimes(1);
});
});
describe('invalid anchors', () => {
it('should throw when anchor is set as type Element', async () => {
ui = await createUiFunction(ctx, {
position: 'inline',
onMount: appendTestApp,
anchor: document.documentElement,
page: name === 'iframe' ? '/page.html' : undefined,
name: 'test-component',
});
expect(() => ui.autoMount()).toThrowError(
'autoMount and Element anchor option cannot be combined. Avoid passing `Element` directly or `() => Element` to the anchor.',
);
});
it('should throw when anchor is set as type `() => Element`', async () => {
ui = await createUiFunction(ctx, {
position: 'inline',
onMount: appendTestApp,
anchor: () => document.documentElement,
page: name === 'iframe' ? '/page.html' : undefined,
name: 'test-component',
});
expect(() => ui.autoMount()).toThrowError(
'autoMount and Element anchor option cannot be combined. Avoid passing `Element` directly or `() => Element` to the anchor.',
);
});
});
describe('StopAutoMount', () => {
it('should stop auto-mounting and remove ui when `ui.remove` is called', async () => {
const onMount = vi.fn(appendTestApp);
const onRemove = vi.fn();
ui = await createUiFunction(ctx, {
position: 'inline',
onMount,
onRemove,
anchor: `#parent > #${DYNAMIC_CHILD_ID}`,
page: name === 'iframe' ? '/page.html' : undefined,
name: 'test-component',
});
let dynamicEl;
ui.autoMount();
await runMicrotasks();
dynamicEl = appendTestElement({ id: DYNAMIC_CHILD_ID });
await runMicrotasks();
await expect
.poll(() => document.querySelector(uiSelector))
.not.toBeNull();
dynamicEl.remove();
await runMicrotasks();
expect(onMount).toHaveBeenCalledTimes(1);
expect(onRemove).toHaveBeenCalledTimes(1);
ui.remove();
dynamicEl = appendTestElement({ id: DYNAMIC_CHILD_ID });
dynamicEl.remove();
await runMicrotasks();
expect(onMount).toHaveBeenCalledTimes(1);
expect(onRemove).toHaveBeenCalledTimes(2);
});
it('should call internal StopAutoMount when `ui.remove` is called', async () => {
const onMount = vi.fn(appendTestApp);
const onRemove = vi.fn();
const onStop = vi.fn();
ui = await createUiFunction(ctx, {
position: 'inline',
onMount,
onRemove,
anchor: `#parent > #${DYNAMIC_CHILD_ID}`,
page: name === 'iframe' ? '/page.html' : undefined,
name: 'test-component',
});
ui.autoMount({ onStop });
ui.remove();
expect(onStop).toHaveBeenCalledTimes(1);
expect(onRemove).toHaveBeenCalledTimes(1);
});
it('should allow calling automount again after internal StopAutoMount is called', async () => {
const onMount = vi.fn(appendTestApp);
ui = await createUiFunction(ctx, {
position: 'inline',
onMount,
anchor: `#parent > #${DYNAMIC_CHILD_ID}`,
page: name === 'iframe' ? '/page.html' : undefined,
name: 'test-component',
});
const onStop = vi.fn();
ui.autoMount({ onStop });
ui.autoMount({ onStop });
ui.remove();
expect(onStop).toBeCalledTimes(1);
ui.autoMount({ onStop });
ui.remove();
expect(onStop).toBeCalledTimes(2);
});
});
},
);
});
});
@@ -1,12 +1,22 @@
import { browser } from 'wxt/browser';
import { waitElement } from '@1natsu/wait-element';
import {
isExist as mountDetector,
isNotExist as removeDetector,
} from '@1natsu/wait-element/detectors';
import { ContentScriptContext } from '..';
import {
AutoMount,
AutoMountOptions,
BaseMountFunctions,
ContentScriptAnchoredOptions,
ContentScriptPositioningOptions,
ContentScriptUiOptions,
IframeContentScriptUi,
IframeContentScriptUiOptions,
IntegratedContentScriptUi,
IntegratedContentScriptUiOptions,
MountFunctions,
ShadowRootContentScriptUi,
ShadowRootContentScriptUiOptions,
} from './types';
@@ -34,10 +44,19 @@ export function createIntegratedUi<TMounted>(
};
const remove = () => {
options.onRemove?.(mounted);
wrapper.replaceChildren();
wrapper.remove();
mounted = undefined;
};
const mountFunctions = createMountFunctions(
{
mount,
remove,
},
options,
);
ctx.onInvalidated(remove);
return {
@@ -45,8 +64,7 @@ export function createIntegratedUi<TMounted>(
return mounted;
},
wrapper,
mount,
remove,
...mountFunctions,
};
}
@@ -78,6 +96,14 @@ export function createIframeUi<TMounted>(
mounted = undefined;
};
const mountFunctions = createMountFunctions(
{
mount,
remove,
},
options,
);
ctx.onInvalidated(remove);
return {
@@ -86,8 +112,7 @@ export function createIframeUi<TMounted>(
},
iframe,
wrapper,
mount,
remove,
...mountFunctions,
};
}
@@ -136,7 +161,7 @@ export async function createShadowRootUi<TMounted>(
const remove = () => {
// Cleanup mounted state
options.onRemove?.(mounted);
// Detatch shadow root from DOM
// Detach shadow root from DOM
shadowHost.remove();
// Remove children from uiContainer
while (uiContainer.lastChild)
@@ -145,14 +170,21 @@ export async function createShadowRootUi<TMounted>(
mounted = undefined;
};
const mountFunctions = createMountFunctions(
{
mount,
remove,
},
options,
);
ctx.onInvalidated(remove);
return {
shadow,
shadowHost,
uiContainer,
mount,
remove,
...mountFunctions,
get mounted() {
return mounted;
},
@@ -255,6 +287,112 @@ function mountUi(
}
}
function createMountFunctions<TMounted>(
baseFunctions: BaseMountFunctions,
options: ContentScriptUiOptions<TMounted>,
): MountFunctions {
let autoMountInstance: AutoMount | undefined = undefined;
const stopAutoMount = () => {
autoMountInstance?.stopAutoMount();
autoMountInstance = undefined;
};
const mount = () => {
baseFunctions.mount();
};
const unmount = baseFunctions.remove;
const remove = () => {
stopAutoMount();
baseFunctions.remove();
};
const autoMount = (autoMountOptions?: AutoMountOptions) => {
if (autoMountInstance) {
logger.warn('autoMount is already set.');
}
autoMountInstance = autoMountUi(
{ mount, unmount, stopAutoMount },
{
...options,
...autoMountOptions,
},
);
};
return {
mount,
remove,
autoMount,
};
}
function autoMountUi(
uiCallbacks: {
mount: () => void;
unmount: () => void;
stopAutoMount: () => void;
},
options: ContentScriptAnchoredOptions & AutoMountOptions,
): AutoMount {
const abortController = new AbortController();
const EXPLICIT_STOP_REASON = 'explicit_stop_auto_mount';
const _stopAutoMount = () => {
abortController.abort(EXPLICIT_STOP_REASON);
options.onStop?.();
};
let resolvedAnchor =
typeof options.anchor === 'function' ? options.anchor() : options.anchor;
if (resolvedAnchor instanceof Element) {
throw Error(
'autoMount and Element anchor option cannot be combined. Avoid passing `Element` directly or `() => Element` to the anchor.',
);
}
async function observeElement(selector: string | null | undefined) {
let isAnchorExist = !!getAnchor(options);
// Mount if anchor exists at initialization.
if (isAnchorExist) {
uiCallbacks.mount();
}
while (!abortController.signal.aborted) {
try {
const changedAnchor = await waitElement(selector ?? 'body', {
customMatcher: () => getAnchor(options) ?? null,
detector: isAnchorExist ? removeDetector : mountDetector,
signal: abortController.signal,
});
isAnchorExist = !!changedAnchor;
if (isAnchorExist) {
uiCallbacks.mount();
} else {
uiCallbacks.unmount();
if (options.once) {
uiCallbacks.stopAutoMount();
}
}
} catch (error) {
if (
abortController.signal.aborted &&
abortController.signal.reason === EXPLICIT_STOP_REASON
) {
break;
} else {
throw error;
}
}
}
}
observeElement(resolvedAnchor);
return { stopAutoMount: _stopAutoMount };
}
/**
* Load the CSS for the current entrypoint.
*/
@@ -36,18 +36,7 @@ export interface ShadowRootContentScriptUi<TMounted>
shadow: ShadowRoot;
}
export interface ContentScriptUi<TMounted> {
/**
* Function that mounts or remounts the UI on the page.
*/
mount: () => void;
/**
* Function that removes the UI from the webpage.
*/
remove: () => void;
/**>
* Custom data returned from the `options.mount` function.
*/
export interface ContentScriptUi<TMounted> extends MountFunctions {
mounted: TMounted | undefined;
}
@@ -216,3 +205,40 @@ export interface ContentScriptAnchoredOptions {
*/
append?: ContentScriptAppendMode | ((anchor: Element, ui: Element) => void);
}
export interface BaseMountFunctions {
/**
* Function that mounts or remounts the UI on the page.
*/
mount: () => void;
/**
* Function that removes the UI from the webpage.
*/
remove: () => void;
}
export interface MountFunctions extends BaseMountFunctions {
/**
* Call `ui.autoMount()` to automatically mount and remove the UI as the anchor is dynamically added/removed by the webpage.
*/
autoMount: (options?: AutoMountOptions) => void;
}
export type AutoMountOptions = {
/**
* When true, only mount and unmount a UI once.
*/
once?: boolean;
/**
* The callback triggered when `StopAutoMount` is called.
*/
onStop?: () => void;
};
export type StopAutoMount = () => void;
export interface AutoMount {
/**
* Stop watching the anchor element for changes, but keep the UI mounted.
*/
stopAutoMount: StopAutoMount;
}
+93 -37
View File
@@ -24,11 +24,13 @@ import { importEntrypointFile } from '../../utils/building';
import { ViteNodeServer } from 'vite-node/server';
import { ViteNodeRunner } from 'vite-node/client';
import { installSourcemapsSupport } from 'vite-node/source-map';
import { createExtensionEnvironment } from '../../utils/environments';
import { relative } from 'node:path';
export async function createViteBuilder(
wxtConfig: ResolvedConfig,
hooks: Hookable<WxtHooks>,
server?: WxtDevServer,
getWxtDevServer?: () => WxtDevServer | undefined,
): Promise<WxtBuilder> {
const vite = await import('vite');
@@ -65,6 +67,8 @@ export async function createViteBuilder(
ignored: [`${wxtConfig.outBaseDir}/**`, `${wxtConfig.wxtDir}/**`],
};
const server = getWxtDevServer?.();
config.plugins ??= [];
config.plugins.push(
wxtPlugins.download(wxtConfig),
@@ -193,7 +197,7 @@ export async function createViteBuilder(
};
/**
* Return the basic config for building a sinlge CSS entrypoint in [multi-page mode](https://vitejs.dev/guide/build.html#multi-page-app).
* Return the basic config for building a single CSS entrypoint in [multi-page mode](https://vitejs.dev/guide/build.html#multi-page-app).
*/
const getCssConfig = (entrypoint: Entrypoint): vite.InlineConfig => {
return {
@@ -218,55 +222,104 @@ export async function createViteBuilder(
};
};
const createViteNodeImporter = async (paths: string[]) => {
const baseConfig = await getBaseConfig({
excludeAnalysisPlugin: true,
});
// Disable dep optimization, as recommended by vite-node's README
baseConfig.optimizeDeps ??= {};
baseConfig.optimizeDeps.noDiscovery = true;
baseConfig.optimizeDeps.include = [];
const envConfig: vite.InlineConfig = {
plugins: paths.map((path) =>
wxtPlugins.removeEntrypointMainFunction(wxtConfig, path),
),
};
const config = vite.mergeConfig(baseConfig, envConfig);
const server = await vite.createServer(config);
await server.pluginContainer.buildStart({});
const node = new ViteNodeServer(
// @ts-ignore: Some weird type error...
server,
);
installSourcemapsSupport({
getSourceMap: (source) => node.getSourceMap(source),
});
const runner = new ViteNodeRunner({
root: server.config.root,
base: server.config.base,
// when having the server and runner in a different context,
// you will need to handle the communication between them
// and pass to this function
fetchModule(id) {
return node.fetchModule(id);
},
resolveId(id, importer) {
return node.resolveId(id, importer);
},
});
return { runner, server };
};
const requireDefaultExport = (path: string, mod: any) => {
const relativePath = relative(wxtConfig.root, path);
if (mod?.default == null) {
const defineFn = relativePath.includes('.content')
? 'defineContentScript'
: relativePath.includes('background')
? 'defineBackground'
: 'defineUnlistedScript';
throw Error(
`${relativePath}: Default export not found, did you forget to call "export default ${defineFn}(...)"?`,
);
}
};
return {
name: 'Vite',
version: vite.version,
async importEntrypoint(path) {
const env = createExtensionEnvironment();
switch (wxtConfig.entrypointLoader) {
default:
case 'jiti': {
return await importEntrypointFile(path);
return await env.run(() => importEntrypointFile(path));
}
case 'vite-node': {
const baseConfig = await getBaseConfig({
excludeAnalysisPlugin: true,
});
// Disable dep optimization, as recommended by vite-node's README
baseConfig.optimizeDeps ??= {};
baseConfig.optimizeDeps.noDiscovery = true;
baseConfig.optimizeDeps.include = [];
const envConfig: vite.InlineConfig = {
plugins: [wxtPlugins.removeEntrypointMainFunction(wxtConfig, path)],
};
const config = vite.mergeConfig(baseConfig, envConfig);
const server = await vite.createServer(config);
await server.pluginContainer.buildStart({});
const node = new ViteNodeServer(
// @ts-ignore: Some weird type error...
server,
);
installSourcemapsSupport({
getSourceMap: (source) => node.getSourceMap(source),
});
const runner = new ViteNodeRunner({
root: server.config.root,
base: server.config.base,
// when having the server and runner in a different context,
// you will need to handle the communication between them
// and pass to this function
fetchModule(id) {
return node.fetchModule(id);
},
resolveId(id, importer) {
return node.resolveId(id, importer);
},
});
const res = await runner.executeFile(path);
const { runner, server } = await createViteNodeImporter([path]);
const res = await env.run(() => runner.executeFile(path));
await server.close();
requireDefaultExport(path, res);
return res.default;
}
}
},
async importEntrypoints(paths) {
const env = createExtensionEnvironment();
switch (wxtConfig.entrypointLoader) {
default:
case 'jiti': {
return await env.run(() =>
Promise.all(paths.map(importEntrypointFile)),
);
}
case 'vite-node': {
const { runner, server } = await createViteNodeImporter(paths);
const res = await env.run(() =>
Promise.all(
paths.map(async (path) => {
const mod = await runner.executeFile(path);
requireDefaultExport(path, mod);
return mod.default;
}),
),
);
await server.close();
return res;
}
}
},
async build(group) {
let entryConfig;
if (Array.isArray(group)) entryConfig = getMultiPageConfig(group);
@@ -319,6 +372,9 @@ export async function createViteBuilder(
},
},
watcher: viteServer.watcher,
on(event, cb) {
viteServer.httpServer?.on(event, cb);
},
};
return server;
+109 -69
View File
@@ -21,12 +21,13 @@ import { createExtensionRunner } from './runners';
import { Mutex } from 'async-mutex';
import pc from 'picocolors';
import { relative } from 'node:path';
import { registerWxt, wxt } from './wxt';
import { deinitWxtModules, initWxtModules, registerWxt, wxt } from './wxt';
import { unnormalizePath } from './utils/paths';
import {
getContentScriptJs,
mapWxtOptionsToRegisteredContentScript,
} from './utils/content-scripts';
import { createKeyboardShortcuts } from './keyboard-shortcuts';
/**
* Creates a dev server and pre-builds all the files that need to exist before loading the extension.
@@ -40,64 +41,120 @@ import {
export async function createServer(
inlineConfig?: InlineConfig,
): Promise<WxtDevServer> {
await registerWxt('serve', inlineConfig, async (config) => {
const { port, hostname } = config.dev.server!;
const serverInfo: ServerInfo = {
await registerWxt('serve', inlineConfig);
wxt.server = await createServerInternal();
await wxt.hooks.callHook('server:created', wxt, wxt.server);
return wxt.server;
}
async function createServerInternal(): Promise<WxtDevServer> {
const getServerInfo = (): ServerInfo => {
const { port, hostname } = wxt.config.dev.server!;
return {
port,
hostname,
origin: `http://${hostname}:${port}`,
};
};
// Server instance must be created first so its reference can be added to the internal config used
// to pre-render entrypoints
const server: WxtDevServer = {
...serverInfo,
get watcher() {
return builderServer.watcher;
},
get ws() {
return builderServer.ws;
},
currentOutput: undefined,
async start() {
await builderServer.listen();
wxt.logger.success(`Started dev server @ ${serverInfo.origin}`);
await buildAndOpenBrowser();
},
async stop() {
await runner.closeBrowser();
await builderServer.close();
},
async restart() {
await closeAndRecreateRunner();
await buildAndOpenBrowser();
},
transformHtml(url, html, originalUrl) {
return builderServer.transformHtml(url, html, originalUrl);
},
reloadContentScript(payload) {
server.ws.send('wxt:reload-content-script', payload);
},
reloadPage(path) {
server.ws.send('wxt:reload-page', path);
},
reloadExtension() {
server.ws.send('wxt:reload-extension');
},
async restartBrowser() {
await closeAndRecreateRunner();
await runner.openBrowser();
},
};
return server;
});
const server = wxt.server!;
let [runner, builderServer] = await Promise.all([
createExtensionRunner(),
wxt.builder.createServer(server),
wxt.builder.createServer(getServerInfo()),
]);
// Used to track if modules need to be re-initialized
let wasStopped = false;
// Server instance must be created first so its reference can be added to the internal config used
// to pre-render entrypoints
const server: WxtDevServer = {
get hostname() {
return getServerInfo().hostname;
},
get port() {
return getServerInfo().port;
},
get origin() {
return getServerInfo().origin;
},
get watcher() {
return builderServer.watcher;
},
get ws() {
return builderServer.ws;
},
currentOutput: undefined,
async start() {
if (wasStopped) {
await wxt.reloadConfig();
runner = await createExtensionRunner();
builderServer = await wxt.builder.createServer(getServerInfo());
await initWxtModules();
}
await builderServer.listen();
wxt.logger.success(`Started dev server @ ${server.origin}`);
await wxt.hooks.callHook('server:started', wxt, server);
// Register content scripts for the first time after the background starts
// up since they're not listed in the manifest.
// Add listener before opening the browser to guarantee it is present when
// the extension sends back the initialization message.
server.ws.on('wxt:background-initialized', () => {
if (server.currentOutput == null) return;
reloadContentScripts(server.currentOutput.steps, server);
});
await buildAndOpenBrowser();
// Listen for file changes and reload different parts of the extension accordingly
const reloadOnChange = createFileReloader(server);
server.watcher.on('all', reloadOnChange);
keyboardShortcuts.start();
keyboardShortcuts.printHelp({
canReopenBrowser:
!wxt.config.runnerConfig.config.disabled && !!runner.canOpen?.(),
});
},
async stop() {
wasStopped = true;
keyboardShortcuts.stop();
await runner.closeBrowser();
await builderServer.close();
await wxt.hooks.callHook('server:closed', wxt, server);
deinitWxtModules();
server.currentOutput = undefined;
},
async restart() {
await server.stop();
await server.start();
},
transformHtml(url, html, originalUrl) {
return builderServer.transformHtml(url, html, originalUrl);
},
reloadContentScript(payload) {
server.ws.send('wxt:reload-content-script', payload);
},
reloadPage(path) {
server.ws.send('wxt:reload-page', path);
},
reloadExtension() {
server.ws.send('wxt:reload-extension');
},
async restartBrowser() {
await runner.closeBrowser();
keyboardShortcuts.stop();
await wxt.reloadConfig();
runner = await createExtensionRunner();
await runner.openBrowser();
keyboardShortcuts.start();
},
};
const keyboardShortcuts = createKeyboardShortcuts(server);
const buildAndOpenBrowser = async () => {
// Build after starting the dev server so it can be used to transform HTML files
server.currentOutput = await internalBuild();
@@ -114,25 +171,7 @@ export async function createServer(
await runner.openBrowser();
};
/**
* Stops the previous runner, grabs the latest config, and recreates the runner.
*/
const closeAndRecreateRunner = async () => {
await runner.closeBrowser();
await wxt.reloadConfig();
runner = await createExtensionRunner();
};
// Register content scripts for the first time after the background starts up since they're not
// listed in the manifest
server.ws.on('wxt:background-initialized', () => {
if (server.currentOutput == null) return;
reloadContentScripts(server.currentOutput.steps, server);
});
// Listen for file changes and reload different parts of the extension accordingly
const reloadOnChange = createFileReloader(server);
server.watcher.on('all', reloadOnChange);
builderServer.on?.('close', () => keyboardShortcuts.stop());
return server;
}
@@ -206,6 +245,7 @@ function createFileReloader(server: WxtDevServer) {
break;
case 'content-script-reload':
reloadContentScripts(changes.changedSteps, server);
const rebuiltNames = changes.rebuildGroups
.flat()
.map((entry) => entry.name);
+2 -1
View File
@@ -78,6 +78,7 @@ async function getPathsDeclarationEntry(
isHtmlEntrypoint(entry) ? '.html' : '.js',
),
)
.concat([''])
.concat(await getPublicFiles());
await wxt.hooks.callHook('prepare:publicPaths', wxt, paths);
@@ -176,7 +177,7 @@ declare module "wxt/browser" {
message.message,
),
),
// Include a final union-based override so TS accepts valid string templates or concatinations
// Include a final union-based override so TS accepts valid string templates or concatenations
// ie: browser.i18n.getMessage(`some_enum_${enumValue}`)
renderGetMessageOverload(
messages.map((message) => `"${message.name}"`).join(' | '),
+2 -5
View File
@@ -11,7 +11,7 @@ export async function initialize(options: {
template: string;
packageManager: string;
}) {
consola.info('Initalizing new project');
consola.info('Initializing new project');
const templates = await listTemplates();
const defaultTemplate = templates.find(
@@ -44,10 +44,7 @@ export async function initialize(options: {
{ title: pc.red('npm'), value: 'npm' },
{ title: pc.yellow('pnpm'), value: 'pnpm' },
{ title: pc.cyan('yarn'), value: 'yarn' },
{
title: `${pc.magenta('bun')}${pc.gray(' (experimental)')}`,
value: 'bun',
},
{ title: pc.magenta('bun'), value: 'bun' },
],
},
],
@@ -0,0 +1,52 @@
import readline from 'node:readline';
import { WxtDevServer } from '../types';
import { wxt } from './wxt';
import pc from 'picocolors';
export interface KeyboardShortcutWatcher {
start(): void;
stop(): void;
printHelp(flags: { canReopenBrowser: boolean }): void;
}
/**
* Function that creates a keyboard shortcut handler for the extension.
*/
export function createKeyboardShortcuts(
server: WxtDevServer,
): KeyboardShortcutWatcher {
let rl: readline.Interface | undefined;
const handleInput = (line: string) => {
// Only handle our specific command
if (line.trim() === 'o') {
server.restartBrowser();
}
};
return {
start() {
if (rl) return;
rl = readline.createInterface({
input: process.stdin,
terminal: false, // Don't intercept ctrl+C, ctrl+Z, etc
});
rl.on('line', handleInput);
},
stop() {
rl?.close();
rl = undefined;
},
printHelp(flags) {
if (flags.canReopenBrowser) {
wxt.logger.info(
`${pc.dim('Press')} ${pc.bold('o + enter')} ${pc.dim('to reopen the browser')}`,
);
}
},
};
}
+14 -4
View File
@@ -27,6 +27,7 @@ import { builtinModules } from '../builtin-modules';
import { getEslintVersion } from './utils/eslint';
import { safeStringToNumber } from './utils/number';
import { loadEnv } from './utils/env';
import { getPort } from 'get-port-please';
/**
* Given an inline config, discover the config file if necessary, merge the results, resolve any
@@ -53,6 +54,9 @@ export async function resolveConfig(
esmResolve: true,
},
});
if (inlineConfig.configFile && metadata.layers?.length === 0) {
throw Error(`Config file "${inlineConfig.configFile}" not found`);
}
userConfig = loadedConfig ?? {};
userConfigMetadata = metadata;
}
@@ -134,14 +138,19 @@ export async function resolveConfig(
let devServerConfig: ResolvedConfig['dev']['server'];
if (command === 'serve') {
const hostname = mergedConfig.dev?.server?.hostname ?? 'localhost';
let port = mergedConfig.dev?.server?.port;
if (port == null || !isFinite(port)) {
const { default: getPort, portNumbers } = await import('get-port');
port = await getPort({ port: portNumbers(3000, 3010) });
port = await getPort({
port: 3000,
portRange: [3001, 3010],
// Passing host required for Mac, unsure of Windows/Linux
host: hostname,
});
}
devServerConfig = {
port,
hostname: mergedConfig.dev?.server?.hostname ?? 'localhost',
hostname,
watchDebounce: safeStringToNumber(process.env.WXT_WATCH_DEBOUNCE) ?? 800,
};
}
@@ -383,10 +392,11 @@ async function getUnimportEslintOptions(
case 'auto':
const version = await getEslintVersion();
let major = parseInt(version[0]);
if (isNaN(major)) eslintEnabled = false;
if (major <= 8) eslintEnabled = 8;
else if (major >= 9) eslintEnabled = 9;
// NaN
else eslintEnabled = 8;
else eslintEnabled = false;
break;
case true:
eslintEnabled = 8;
+8 -2
View File
@@ -11,6 +11,9 @@ export function createWebExtRunner(): ExtensionRunner {
let runner: WebExtRunInstance | undefined;
return {
canOpen() {
return true;
},
async openBrowser() {
const startTime = Date.now();
@@ -32,7 +35,7 @@ export function createWebExtRunner(): ExtensionRunner {
const wxtUserConfig = wxt.config.runnerConfig.config;
const userConfig = {
console: wxtUserConfig?.openConsole,
browserConsole: wxtUserConfig?.openConsole,
devtools: wxtUserConfig?.openDevtools,
startUrl: wxtUserConfig?.startUrls,
keepProfileChanges: wxtUserConfig?.keepProfileChanges,
@@ -50,7 +53,10 @@ export function createWebExtRunner(): ExtensionRunner {
wxtUserConfig?.chromiumPref,
DEFAULT_CHROMIUM_PREFS,
),
args: wxtUserConfig?.chromiumArgs,
args: [
'--unsafely-disable-devtools-self-xss-warnings',
...(wxtUserConfig?.chromiumArgs ?? []),
],
}),
};
@@ -44,6 +44,7 @@ describe('Manifest Utils', () => {
defaultTitle: 'Default Iitle',
},
outputDir: outDir,
skipped: false,
});
it('should include an action for mv3', async () => {
@@ -209,6 +210,7 @@ describe('Manifest Utils', () => {
chromeStyle: true,
browserStyle: true,
},
skipped: false,
});
it('should include a options_ui and chrome_style for chrome', async () => {
@@ -265,6 +267,7 @@ describe('Manifest Utils', () => {
persistent: true,
type: 'module',
},
skipped: false,
});
describe('MV3', () => {
@@ -368,7 +371,7 @@ describe('Manifest Utils', () => {
describe('icons', () => {
it('should auto-discover icons with the correct name', async () => {
const entrypoints = fakeArray(fakeEntrypoint);
const entrypoints = fakeArray(() => fakeEntrypoint({ skipped: false }));
const buildOutput = fakeBuildOutput({
publicAssets: [
{ type: 'asset', fileName: 'icon-16.png' },
@@ -396,7 +399,7 @@ describe('Manifest Utils', () => {
});
it('should return undefined when no icons are found', async () => {
const entrypoints = fakeArray(fakeEntrypoint);
const entrypoints = fakeArray(() => fakeEntrypoint({ skipped: false }));
const buildOutput = fakeBuildOutput({
publicAssets: [
{ type: 'asset', fileName: 'logo.png' },
@@ -413,7 +416,7 @@ describe('Manifest Utils', () => {
});
it('should allow icons to be overwritten from the wxt.config.ts file', async () => {
const entrypoints = fakeArray(fakeEntrypoint);
const entrypoints = fakeArray(() => fakeEntrypoint({ skipped: false }));
const buildOutput = fakeBuildOutput({
publicAssets: [
{ type: 'asset', fileName: 'icon-16.png' },
@@ -837,6 +840,7 @@ describe('Manifest Utils', () => {
options: {
registration: 'runtime',
},
skipped: false,
});
const entrypoints = [cs];
@@ -902,6 +906,7 @@ describe('Manifest Utils', () => {
async (browser) => {
const sidepanel = fakeSidepanelEntrypoint({
outputDir: outDir,
skipped: false,
});
const buildOutput = fakeBuildOutput();
@@ -934,6 +939,7 @@ describe('Manifest Utils', () => {
async (browser) => {
const sidepanel = fakeSidepanelEntrypoint({
outputDir: outDir,
skipped: false,
});
const buildOutput = fakeBuildOutput();
@@ -1581,6 +1587,64 @@ describe('Manifest Utils', () => {
permissions: ['tabs', 'scripting'],
});
});
it('should convert MV3 CSP object to MV2 CSP string with localhost for MV2', async () => {
const entrypoints: Entrypoint[] = [];
const buildOutput = fakeBuildOutput();
const inputCsp =
"script-src 'self' 'wasm-unsafe-eval'; object-src 'self';";
const expectedCsp =
"script-src 'self' 'wasm-unsafe-eval' http://localhost:3000; object-src 'self';";
// Setup WXT for Firefox and serve command
setFakeWxt({
config: {
browser: 'firefox',
command: 'serve',
manifestVersion: 2,
manifest: {
content_security_policy: {
extension_pages: inputCsp,
},
},
},
server: fakeWxtDevServer({
port: 3000,
hostname: 'localhost',
origin: 'http://localhost:3000',
}),
});
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
);
expect(actual.content_security_policy).toEqual(expectedCsp);
});
});
it('should not add skipped entrypoints to manifest', async () => {
const popup = fakePopupEntrypoint({ skipped: true });
const content = fakeContentScriptEntrypoint({ skipped: true });
const sidePanel = fakeSidepanelEntrypoint({ skipped: true });
const buildOutput = fakeBuildOutput();
setFakeWxt({
config: {
command: 'build',
manifestVersion: 3,
},
});
const { manifest } = await generateManifest(
[popup, content, sidePanel],
buildOutput,
);
expect(manifest.action).toBeUndefined();
expect(manifest.sidebar_action).toBeUndefined();
expect(manifest.content_scripts).toBeUndefined();
});
});
@@ -72,9 +72,10 @@ describe('Validation Utils', () => {
expect(actual).toEqual(expected);
});
it("should return an error when content scripts don't have a matches", () => {
it('should return an error when "registration: manifest" content scripts don\'t have matches', () => {
const entrypoint = fakeContentScriptEntrypoint({
options: {
registration: 'manifest',
// @ts-expect-error
matches: null,
},
@@ -83,7 +84,8 @@ describe('Validation Utils', () => {
errors: [
{
type: 'error',
message: '`matches` is required',
message:
'`matches` is required for manifest registered content scripts',
value: null,
entrypoint,
},
@@ -96,5 +98,24 @@ describe('Validation Utils', () => {
expect(actual).toEqual(expected);
});
it('should allow "registration: runtime" content scripts to not have matches', () => {
const entrypoint = fakeContentScriptEntrypoint({
options: {
registration: 'runtime',
// @ts-expect-error
matches: null,
},
});
const expected = {
errors: [],
errorCount: 0,
warningCount: 0,
};
const actual = validateEntrypoints([entrypoint]);
expect(actual).toEqual(expected);
});
});
});
@@ -71,6 +71,33 @@ describe('Detect Dev Changes', () => {
});
});
describe('modules/*', () => {
it("should return 'full-restart' when one of the changed files is in the WXT modules folder", () => {
const modulesDir = '/root/modules';
setFakeWxt({
config: {
modulesDir,
},
});
const changes = [
'/root/src/public/image.svg',
`${modulesDir}/example.ts`,
];
const currentOutput: BuildOutput = {
manifest: fakeManifest(),
publicAssets: [],
steps: [],
};
const expected: DevModeChange = {
type: 'full-restart',
};
const actual = detectDevChanges(changes, currentOutput);
expect(actual).toEqual(expected);
});
});
describe('web-ext.config.ts', () => {
it("should return 'browser-restart' when one of the changed files is the config file", () => {
const runnerFile = '/root/web-ext.config.ts';
@@ -36,11 +36,12 @@ describe('findEntrypoints', () => {
outDir: resolve('.output'),
command: 'build',
});
let importEntrypointMock: Mock;
let importEntrypointsMock: Mock<typeof wxt.builder.importEntrypoints>;
beforeEach(() => {
setFakeWxt({ config });
importEntrypointMock = vi.mocked(wxt.builder.importEntrypoint);
importEntrypointsMock = vi.mocked(wxt.builder.importEntrypoints);
importEntrypointsMock.mockResolvedValue([]);
});
it.each<[string, string, PopupEntrypoint]>([
@@ -210,13 +211,13 @@ describe('findEntrypoints', () => {
matches: ['<all_urls>'],
};
globMock.mockResolvedValueOnce([path]);
importEntrypointMock.mockResolvedValue(options);
importEntrypointsMock.mockResolvedValue([options]);
const entrypoints = await findEntrypoints();
expect(entrypoints).toHaveLength(1);
expect(entrypoints[0]).toEqual({ ...expected, options });
expect(importEntrypointMock).toBeCalledWith(expected.inputPath);
expect(importEntrypointsMock).toBeCalledWith([expected.inputPath]);
},
);
@@ -244,17 +245,17 @@ describe('findEntrypoints', () => {
])(
'should find and load background entrypoint config from %s',
async (path, expected) => {
const options: BackgroundEntrypointOptions = {
const options = {
type: 'module',
};
} satisfies BackgroundEntrypointOptions;
globMock.mockResolvedValueOnce([path]);
importEntrypointMock.mockResolvedValue(options);
importEntrypointsMock.mockResolvedValue([options]);
const entrypoints = await findEntrypoints();
expect(entrypoints).toHaveLength(1);
expect(entrypoints[0]).toEqual({ ...expected, options });
expect(importEntrypointMock).toBeCalledWith(expected.inputPath);
expect(importEntrypointsMock).toBeCalledWith([expected.inputPath]);
},
);
@@ -339,11 +340,11 @@ describe('findEntrypoints', () => {
},
builder: wxt.builder,
});
const options: BackgroundEntrypointOptions = {
const options = {
type: 'module',
};
} satisfies BackgroundEntrypointOptions;
globMock.mockResolvedValueOnce(['background.ts']);
importEntrypointMock.mockResolvedValue(options);
importEntrypointsMock.mockResolvedValue([options]);
const entrypoints = await findEntrypoints();
@@ -357,11 +358,11 @@ describe('findEntrypoints', () => {
},
builder: wxt.builder,
});
const options: BackgroundEntrypointOptions = {
const options = {
type: 'module',
};
} satisfies BackgroundEntrypointOptions;
globMock.mockResolvedValueOnce(['background.ts']);
importEntrypointMock.mockResolvedValue(options);
importEntrypointsMock.mockResolvedValue([options]);
const entrypoints = await findEntrypoints();
@@ -410,15 +411,15 @@ describe('findEntrypoints', () => {
outputDir: config.outDir,
skipped: false,
};
const options: BaseEntrypointOptions = {};
const options = {} satisfies BaseEntrypointOptions;
globMock.mockResolvedValueOnce([path]);
importEntrypointMock.mockResolvedValue(options);
importEntrypointsMock.mockResolvedValue([options]);
const entrypoints = await findEntrypoints();
expect(entrypoints).toHaveLength(1);
expect(entrypoints[0]).toEqual({ ...expected, options });
expect(importEntrypointMock).toBeCalledWith(expected.inputPath);
expect(importEntrypointsMock).toBeCalledWith([expected.inputPath]);
},
);
@@ -701,29 +702,39 @@ describe('findEntrypoints', () => {
});
describe('include option', () => {
it("should filter out the background when include doesn't contain the target browser", async () => {
it("should mark the background as skipped when include doesn't contain the target browser", async () => {
globMock.mockResolvedValueOnce(['background.ts']);
importEntrypointMock.mockResolvedValue({
include: ['not' + config.browser],
});
importEntrypointsMock.mockResolvedValue([
{ include: ['not' + config.browser] },
]);
const entrypoints = await findEntrypoints();
expect(entrypoints).toEqual([]);
expect(entrypoints).toEqual([
expect.objectContaining({
name: 'background',
skipped: true,
}),
]);
});
it("should filter out content scripts when include doesn't contain the target browser", async () => {
it("should mark content scripts as skipped when include doesn't contain the target browser", async () => {
globMock.mockResolvedValueOnce(['example.content.ts']);
importEntrypointMock.mockResolvedValue({
include: ['not' + config.browser],
});
importEntrypointsMock.mockResolvedValue([
{ include: ['not' + config.browser] },
]);
const entrypoints = await findEntrypoints();
expect(entrypoints).toEqual([]);
expect(entrypoints).toEqual([
expect.objectContaining({
name: 'example',
skipped: true,
}),
]);
});
it("should filter out the popup when include doesn't contain the target browser", async () => {
it("should mark the popup as skipped when include doesn't contain the target browser", async () => {
globMock.mockResolvedValueOnce(['popup.html']);
readFileMock.mockResolvedValueOnce(
`<html>
@@ -737,10 +748,15 @@ describe('findEntrypoints', () => {
const entrypoints = await findEntrypoints();
expect(entrypoints).toEqual([]);
expect(entrypoints).toEqual([
expect.objectContaining({
name: 'popup',
skipped: true,
}),
]);
});
it("should filter out the options page when include doesn't contain the target browser", async () => {
it("should mark the options page as skipped when include doesn't contain the target browser", async () => {
globMock.mockResolvedValueOnce(['options.html']);
readFileMock.mockResolvedValueOnce(
`<html>
@@ -754,10 +770,15 @@ describe('findEntrypoints', () => {
const entrypoints = await findEntrypoints();
expect(entrypoints).toEqual([]);
expect(entrypoints).toEqual([
expect.objectContaining({
name: 'options',
skipped: true,
}),
]);
});
it("should filter out an unlisted page when include doesn't contain the target browser", async () => {
it("should mark unlisted pages as skipped when include doesn't contain the target browser", async () => {
globMock.mockResolvedValueOnce(['unlisted.html']);
readFileMock.mockResolvedValueOnce(
`<html>
@@ -771,34 +792,45 @@ describe('findEntrypoints', () => {
const entrypoints = await findEntrypoints();
expect(entrypoints).toEqual([]);
expect(entrypoints).toEqual([
expect.objectContaining({
name: 'unlisted',
skipped: true,
}),
]);
});
});
describe('exclude option', () => {
it('should filter out the background when exclude contains the target browser', async () => {
it('should mark the background as skipped when exclude contains the target browser', async () => {
globMock.mockResolvedValueOnce(['background.ts']);
importEntrypointMock.mockResolvedValue({
exclude: [config.browser],
});
importEntrypointsMock.mockResolvedValue([{ exclude: [config.browser] }]);
const entrypoints = await findEntrypoints();
expect(entrypoints).toEqual([]);
expect(entrypoints).toEqual([
expect.objectContaining({
name: 'background',
skipped: true,
}),
]);
});
it('should filter out content scripts when exclude contains the target browser', async () => {
it('should mark content scripts as skipped when exclude contains the target browser', async () => {
globMock.mockResolvedValueOnce(['example.content.ts']);
importEntrypointMock.mockResolvedValue({
exclude: [config.browser],
});
importEntrypointsMock.mockResolvedValue([{ exclude: [config.browser] }]);
const entrypoints = await findEntrypoints();
expect(entrypoints).toEqual([]);
expect(entrypoints).toEqual([
expect.objectContaining({
name: 'example',
skipped: true,
}),
]);
});
it('should filter out the popup when exclude contains the target browser', async () => {
it('should mark the popup as skipped when exclude contains the target browser', async () => {
globMock.mockResolvedValueOnce(['popup.html']);
readFileMock.mockResolvedValueOnce(
`<html>
@@ -810,10 +842,15 @@ describe('findEntrypoints', () => {
const entrypoints = await findEntrypoints();
expect(entrypoints).toEqual([]);
expect(entrypoints).toEqual([
expect.objectContaining({
name: 'popup',
skipped: true,
}),
]);
});
it('should filter out the options page when exclude contains the target browser', async () => {
it('should mark the options page as skipped when exclude contains the target browser', async () => {
globMock.mockResolvedValueOnce(['options.html']);
readFileMock.mockResolvedValueOnce(
`<html>
@@ -825,10 +862,15 @@ describe('findEntrypoints', () => {
const entrypoints = await findEntrypoints();
expect(entrypoints).toEqual([]);
expect(entrypoints).toEqual([
expect.objectContaining({
name: 'options',
skipped: true,
}),
]);
});
it('should filter out an unlisted page when exclude contains the target browser', async () => {
it('should mark unlisted pages as skipped when exclude contains the target browser', async () => {
globMock.mockResolvedValueOnce(['unlisted.html']);
readFileMock.mockResolvedValueOnce(
`<html>
@@ -840,12 +882,17 @@ describe('findEntrypoints', () => {
const entrypoints = await findEntrypoints();
expect(entrypoints).toEqual([]);
expect(entrypoints).toEqual([
expect.objectContaining({
name: 'unlisted',
skipped: true,
}),
]);
});
});
describe('filterEntrypoints option', () => {
it('should control entrypoints accessible', async () => {
it('should override include/exclude of individual entrypoint options', async () => {
globMock.mockResolvedValue([
'options/index.html',
'popup/index.html',
@@ -864,12 +911,28 @@ describe('findEntrypoints', () => {
builder: wxt.builder,
});
importEntrypointMock.mockResolvedValue({});
importEntrypointsMock.mockResolvedValue([{}]);
const entrypoints = await findEntrypoints();
const names = entrypoints.map((item) => item.name);
expect(names).toHaveLength(2);
expect(names).toEqual(filterEntrypoints);
expect(entrypoints).toEqual([
expect.objectContaining({
name: 'injected',
skipped: true,
}),
expect.objectContaining({
name: 'options',
skipped: true,
}),
expect.objectContaining({
name: 'popup',
skipped: false,
}),
expect.objectContaining({
name: 'ui',
skipped: false,
}),
]);
});
});
});
@@ -152,12 +152,16 @@ describe('groupEntrypoints', () => {
options: {
type: 'module',
},
skipped: false,
});
const popup = fakePopupEntrypoint({
skipped: false,
});
const popup = fakePopupEntrypoint();
const sandbox = fakeGenericEntrypoint({
inputPath: '/entrypoints/sandbox.html',
name: 'sandbox',
type: 'sandbox',
skipped: false,
});
const actual = groupEntrypoints([background, popup, sandbox]);
@@ -165,6 +169,22 @@ describe('groupEntrypoints', () => {
expect(actual).toEqual([[background, popup], [sandbox]]);
});
it('should exclude skipped entrypoints from the groups to build', () => {
const background = fakeBackgroundEntrypoint({
options: {
type: 'module',
},
skipped: false,
});
const popup = fakePopupEntrypoint({
skipped: true,
});
const actual = groupEntrypoints([background, popup]);
expect(actual).toEqual([[background]]);
});
it.todo(
'should group ESM compatible sandbox scripts with sandbox pages',
() => {
@@ -26,7 +26,11 @@ import { wxt } from '../../wxt';
* - Background script is changed
* - Manifest is different
* - Restart browser
* - Config file changed (wxt.config.ts, .env, web-ext.config.ts, etc)
* - web-ext.config.ts (runner config changes)
* - Full dev server restart
* - wxt.config.ts (main config file)
* - modules/* (any file related to WXT modules)
* - .env (environment variable changed could effect build)
*/
export function detectDevChanges(
changedFiles: string[],
@@ -38,6 +42,11 @@ export function detectDevChanges(
);
if (isConfigChange) return { type: 'full-restart' };
const isWxtModuleChange = some(changedFiles, (file) =>
file.startsWith(wxt.config.modulesDir),
);
if (isWxtModuleChange) return { type: 'full-restart' };
const isRunnerChange = some(
changedFiles,
(file) => file === wxt.config.runnerConfig.configFile,
@@ -1,19 +1,15 @@
import { relative, resolve } from 'path';
import {
BackgroundEntrypoint,
BackgroundDefinition,
BaseEntrypointOptions,
ContentScriptDefinition,
ContentScriptEntrypoint,
Entrypoint,
EntrypointInfo,
GenericEntrypoint,
OptionsEntrypoint,
PopupEntrypoint,
UnlistedScriptDefinition,
PopupEntrypointOptions,
OptionsEntrypointOptions,
SidepanelEntrypoint,
SidepanelEntrypointOptions,
MainWorldContentScriptEntrypointOptions,
IsolatedWorldContentScriptEntrypointOptions,
} from '../../../types';
import fs from 'fs-extra';
import { minimatch } from 'minimatch';
@@ -22,13 +18,15 @@ import JSON5 from 'json5';
import glob from 'fast-glob';
import {
getEntrypointName,
isHtmlEntrypoint,
isJsEntrypoint,
resolvePerBrowserOptions,
} from '../../utils/entrypoints';
import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '../../utils/constants';
import { CSS_EXTENSIONS_PATTERN } from '../../utils/paths';
import pc from 'picocolors';
import { wxt } from '../../wxt';
import { createExtensionEnvironment } from '../environments';
import { camelCase } from 'scule';
/**
* Return entrypoints and their configuration by looking through the project's files.
@@ -65,126 +63,156 @@ export async function findEntrypoints(): Promise<Entrypoint[]> {
);
if (matchingGlob) {
const type = PATH_GLOB_TO_TYPE_MAP[matchingGlob];
results.push({
name,
inputPath,
type,
skipped:
wxt.config.filterEntrypoints != null &&
!wxt.config.filterEntrypoints.has(name),
});
results.push({ name, inputPath, type });
}
return results;
}, []);
await wxt.hooks.callHook('entrypoints:found', wxt, entrypointInfos);
// Validation
preventNoEntrypoints(entrypointInfos);
preventDuplicateEntrypointNames(entrypointInfos);
// Import entrypoints to get their config
let hasBackground = false;
const env = createExtensionEnvironment();
const entrypoints: Entrypoint[] = await env.run(() =>
Promise.all(
entrypointInfos.map(async (info): Promise<Entrypoint> => {
const { type } = info;
switch (type) {
case 'popup':
return await getPopupEntrypoint(info);
case 'sidepanel':
return await getSidepanelEntrypoint(info);
case 'options':
return await getOptionsEntrypoint(info);
case 'background':
hasBackground = true;
return await getBackgroundEntrypoint(info);
case 'content-script':
return await getContentScriptEntrypoint(info);
case 'unlisted-page':
return await getUnlistedPageEntrypoint(info);
case 'unlisted-script':
return await getUnlistedScriptEntrypoint(info);
case 'content-script-style':
return {
...info,
type,
outputDir: resolve(wxt.config.outDir, CONTENT_SCRIPT_OUT_DIR),
options: {
include: undefined,
exclude: undefined,
},
};
default:
return {
...info,
type,
outputDir: wxt.config.outDir,
options: {
include: undefined,
exclude: undefined,
},
};
}
}),
),
const entrypointOptions = await importEntrypoints(entrypointInfos);
const entrypointsWithoutSkipped: Entrypoint[] = await Promise.all(
entrypointInfos.map(async (info): Promise<Entrypoint> => {
const { type } = info;
const options = entrypointOptions[info.inputPath] ?? {};
switch (type) {
case 'popup':
return await getPopupEntrypoint(info, options);
case 'sidepanel':
return await getSidepanelEntrypoint(info, options);
case 'options':
return await getOptionsEntrypoint(info, options);
case 'background':
hasBackground = true;
return await getBackgroundEntrypoint(info, options);
case 'content-script':
return await getContentScriptEntrypoint(info, options);
case 'unlisted-page':
return await getUnlistedPageEntrypoint(info, options);
case 'unlisted-script':
return await getUnlistedScriptEntrypoint(info, options);
case 'content-script-style':
return {
...info,
type,
outputDir: resolve(wxt.config.outDir, CONTENT_SCRIPT_OUT_DIR),
options: {
include: options.include,
exclude: options.exclude,
},
};
default:
return {
...info,
type,
outputDir: wxt.config.outDir,
options: {
include: options.include,
exclude: options.exclude,
},
};
}
}),
);
if (wxt.config.command === 'serve' && !hasBackground) {
entrypoints.push(
await getBackgroundEntrypoint({
inputPath: VIRTUAL_NOOP_BACKGROUND_MODULE_ID,
name: 'background',
type: 'background',
skipped: false,
}),
entrypointsWithoutSkipped.push(
await getBackgroundEntrypoint(
{
inputPath: VIRTUAL_NOOP_BACKGROUND_MODULE_ID,
name: 'background',
type: 'background',
},
{},
),
);
}
// Mark entrypoints as skipped or not
const entrypoints = entrypointsWithoutSkipped.map((entry) => ({
...entry,
skipped: isEntrypointSkipped(entry),
}));
await wxt.hooks.callHook('entrypoints:resolved', wxt, entrypoints);
wxt.logger.debug('All entrypoints:', entrypoints);
const skippedEntrypointNames = entrypointInfos
const skippedEntrypointNames = entrypoints
.filter((item) => item.skipped)
.map((item) => item.name);
if (skippedEntrypointNames.length) {
wxt.logger.warn(
`Filter excluded the following entrypoints:\n${skippedEntrypointNames
.map((item) => `${pc.dim('-')} ${pc.cyan(item)}`)
.join('\n')}`,
[
'The following entrypoints have been skipped:',
...skippedEntrypointNames.map(
(item) => `${pc.dim('-')} ${pc.cyan(item)}`,
),
].join('\n'),
);
}
const targetEntrypoints = entrypoints.filter((entry) => {
const { include, exclude } = entry.options;
if (include?.length && exclude?.length) {
wxt.logger.warn(
`The ${entry.name} entrypoint lists both include and exclude, but only one can be used per entrypoint. Entrypoint ignored.`,
);
return false;
}
if (exclude?.length && !include?.length) {
return !exclude.includes(wxt.config.browser);
}
if (include?.length && !exclude?.length) {
return include.includes(wxt.config.browser);
}
if (skippedEntrypointNames.includes(entry.name)) {
return false;
}
return true;
});
wxt.logger.debug(`${wxt.config.browser} entrypoints:`, targetEntrypoints);
await wxt.hooks.callHook('entrypoints:resolved', wxt, targetEntrypoints);
return targetEntrypoints;
return entrypoints;
}
interface EntrypointInfo {
name: string;
inputPath: string;
type: Entrypoint['type'];
/**
* @default false
*/
skipped: boolean;
/** Returns a map of input paths to the file's options. */
async function importEntrypoints(infos: EntrypointInfo[]) {
const resMap: Record<string, Record<string, any> | undefined> = {};
const htmlInfos = infos.filter((info) => isHtmlEntrypoint(info));
const jsInfos = infos.filter((info) => isJsEntrypoint(info));
await Promise.all([
// HTML
...htmlInfos.map(async (info) => {
const res = await importHtmlEntrypoint(info);
resMap[info.inputPath] = res;
}),
// JS
(async () => {
const res = await wxt.builder.importEntrypoints(
jsInfos.map((info) => info.inputPath),
);
res.forEach((res, i) => {
resMap[jsInfos[i].inputPath] = res;
});
})(),
// CSS - never has options
]);
return resMap;
}
/** Extract `manifest.` options from meta tags, converting snake_case keys to camelCase */
async function importHtmlEntrypoint(
info: EntrypointInfo,
): Promise<Record<string, any>> {
const content = await fs.readFile(info.inputPath, 'utf-8');
const { document } = parseHTML(content);
const metaTags = document.querySelectorAll('meta');
const res: Record<string, any> = {
title: document.querySelector('title')?.textContent || undefined,
};
metaTags.forEach((tag) => {
const name = tag.name;
if (!name.startsWith('manifest.')) return;
const key = camelCase(name.slice(9));
try {
res[key] = JSON5.parse(tag.content);
} catch {
res[key] = tag.content;
}
});
return res;
}
function preventDuplicateEntrypointNames(files: EntrypointInfo[]) {
@@ -224,122 +252,105 @@ function preventNoEntrypoints(files: EntrypointInfo[]) {
async function getPopupEntrypoint(
info: EntrypointInfo,
options: Record<string, any>,
): Promise<PopupEntrypoint> {
const options = await getHtmlEntrypointOptions<PopupEntrypointOptions>(
info,
const stictOptions: PopupEntrypoint['options'] = resolvePerBrowserOptions(
{
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',
browserStyle: options.browserStyle,
exclude: options.exclude,
include: options.include,
defaultIcon: options.defaultIcon,
defaultTitle: options.title,
mv2Key: options.type,
},
wxt.config.browser,
);
if (stictOptions.mv2Key && stictOptions.mv2Key !== 'page_action')
stictOptions.mv2Key = 'browser_action';
return {
type: 'popup',
name: 'popup',
options: resolvePerBrowserOptions(options, wxt.config.browser),
options: stictOptions,
inputPath: info.inputPath,
outputDir: wxt.config.outDir,
skipped: info.skipped,
};
}
async function getOptionsEntrypoint(
info: EntrypointInfo,
options: Record<string, any>,
): 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: resolvePerBrowserOptions(options, wxt.config.browser),
options: resolvePerBrowserOptions(
{
browserStyle: options.browserStyle,
chromeStyle: options.chromeStyle,
exclude: options.exclude,
include: options.include,
openInTab: options.openInTab,
},
wxt.config.browser,
),
inputPath: info.inputPath,
outputDir: wxt.config.outDir,
skipped: info.skipped,
};
}
async function getUnlistedPageEntrypoint(
info: EntrypointInfo,
options: Record<string, any>,
): Promise<GenericEntrypoint> {
const options = await getHtmlEntrypointOptions<BaseEntrypointOptions>(info, {
exclude: 'exclude',
include: 'include',
});
return {
type: 'unlisted-page',
name: info.name,
inputPath: info.inputPath,
outputDir: wxt.config.outDir,
options,
skipped: info.skipped,
options: {
include: options.include,
exclude: options.exclude,
},
};
}
async function getUnlistedScriptEntrypoint({
inputPath,
name,
skipped,
}: EntrypointInfo): Promise<GenericEntrypoint> {
const defaultExport =
await wxt.builder.importEntrypoint<UnlistedScriptDefinition>(inputPath);
if (defaultExport == null) {
throw Error(
`${name}: Default export not found, did you forget to call "export default defineUnlistedScript(...)"?`,
);
}
const { main: _, ...options } = defaultExport;
async function getUnlistedScriptEntrypoint(
{ inputPath, name }: EntrypointInfo,
options: Record<string, any>,
): Promise<GenericEntrypoint> {
return {
type: 'unlisted-script',
name,
inputPath,
outputDir: wxt.config.outDir,
options: resolvePerBrowserOptions(options, wxt.config.browser),
skipped,
options: resolvePerBrowserOptions(
{
include: options.include,
exclude: options.exclude,
},
wxt.config.browser,
),
};
}
async function getBackgroundEntrypoint({
inputPath,
name,
skipped,
}: EntrypointInfo): Promise<BackgroundEntrypoint> {
let options: Omit<BackgroundDefinition, 'main'> = {};
if (inputPath !== VIRTUAL_NOOP_BACKGROUND_MODULE_ID) {
const defaultExport =
await wxt.builder.importEntrypoint<BackgroundDefinition>(inputPath);
if (defaultExport == null) {
throw Error(
`${name}: Default export not found, did you forget to call "export default defineBackground(...)"?`,
);
}
const { main: _, ...moduleOptions } = defaultExport;
options = moduleOptions;
}
async function getBackgroundEntrypoint(
{ inputPath, name }: EntrypointInfo,
options: Record<string, any>,
): Promise<BackgroundEntrypoint> {
const strictOptions: BackgroundEntrypoint['options'] =
resolvePerBrowserOptions(
{
include: options.include,
exclude: options.exclude,
persistent: options.persistent,
type: options.type,
},
wxt.config.browser,
);
if (wxt.config.manifestVersion !== 3) {
delete options.type;
delete strictOptions.type;
}
return {
@@ -347,114 +358,72 @@ async function getBackgroundEntrypoint({
name,
inputPath,
outputDir: wxt.config.outDir,
options: resolvePerBrowserOptions(options, wxt.config.browser),
skipped,
options: strictOptions,
};
}
async function getContentScriptEntrypoint({
inputPath,
name,
skipped,
}: EntrypointInfo): Promise<ContentScriptEntrypoint> {
const defaultExport =
await wxt.builder.importEntrypoint<ContentScriptDefinition>(inputPath);
if (defaultExport == null) {
throw Error(
`${name}: Default export not found, did you forget to call "export default defineContentScript(...)"?`,
);
}
const { main: _, ...options } = defaultExport;
if (options == null) {
throw Error(
`${name}: Default export not found, did you forget to call "export default defineContentScript(...)"?`,
);
}
async function getContentScriptEntrypoint(
{ inputPath, name }: EntrypointInfo,
options: Record<string, any>,
): Promise<ContentScriptEntrypoint> {
return {
type: 'content-script',
name,
inputPath,
outputDir: resolve(wxt.config.outDir, CONTENT_SCRIPT_OUT_DIR),
options: resolvePerBrowserOptions(options, wxt.config.browser),
skipped,
options: resolvePerBrowserOptions(
options as
| MainWorldContentScriptEntrypointOptions
| IsolatedWorldContentScriptEntrypointOptions,
wxt.config.browser,
),
};
}
async function getSidepanelEntrypoint(
info: EntrypointInfo,
options: Record<string, any>,
): Promise<SidepanelEntrypoint> {
const options = await getHtmlEntrypointOptions<SidepanelEntrypointOptions>(
info,
{
browserStyle: 'browser_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),
options: resolvePerBrowserOptions(
{
browserStyle: options.browserStyle,
exclude: options.exclude,
include: options.include,
defaultIcon: options.defaultIcon,
defaultTitle: options.title,
openAtInstall: options.openAtInstall,
},
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);
function isEntrypointSkipped(entry: Omit<Entrypoint, 'skipped'>): boolean {
if (wxt.config.filterEntrypoints != null) {
return !wxt.config.filterEntrypoints.has(entry.name);
}
const options = {} as T;
const { include, exclude } = entry.options;
if (include?.length && exclude?.length) {
wxt.logger.warn(
`The ${entry.name} entrypoint lists both include and exclude, but only one can be used per entrypoint. Entrypoint skipped.`,
);
return true;
}
const defaultQuery = (manifestKey: string) =>
document
.querySelector(`meta[name='manifest.${manifestKey}']`)
?.getAttribute('content');
if (exclude?.length && !include?.length) {
return exclude.includes(wxt.config.browser);
}
if (include?.length && !exclude?.length) {
return !include.includes(wxt.config.browser);
}
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;
return false;
}
const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type']> = {
@@ -1,8 +1,8 @@
import { Entrypoint, EntrypointGroup } from '../../../types';
/**
* Entrypoints can be build in groups. HTML pages can all be built together in a single step, while
* content scripts must be build individually.
* Entrypoints are built in groups. HTML pages can all be built together in a single step,
* content scripts must be build individually, etc.
*
* This function returns the entrypoints put into these types of groups.
*/
@@ -11,6 +11,8 @@ export function groupEntrypoints(entrypoints: Entrypoint[]): EntrypointGroup[] {
const groups: EntrypointGroup[] = [];
for (const entry of entrypoints) {
if (entry.skipped) continue;
let group = ENTRY_TYPE_TO_GROUP_MAP[entry.type];
if (entry.type === 'background' && entry.options.type === 'module') {
group = 'esm';

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