Compare commits

...

64 Commits

Author SHA1 Message Date
GitHub Actions 8c834835c4 chore(release): v0.13.5 2024-01-08 20:10:48 +00:00
Aaron 9a2cc18f64 chore: Update bug report template 2024-01-08 13:50:20 -06:00
Aaron Klinker 8683bd4b77 docs: Add section about customizing other browser options during development 2024-01-08 13:42:50 -06:00
Aaron dca52864bb fix: Strip path from web_accessible_resources[0].matches (#332) 2024-01-08 13:17:31 -06:00
GitHub Actions 0c5643f648 chore(release): v0.13.4 2024-01-07 16:51:31 +00:00
Aaron Klinker b7cdf1597c fix: Disable minification during development
This closes #328
2024-01-07 10:48:25 -06:00
Aaron Klinker 2770974261 chore: Use const instead of let 2024-01-07 10:46:49 -06:00
GitHub Actions ab6a34afb6 chore(release): v0.13.3 2024-01-05 05:14:03 +00:00
Aaron b0eb7da9b4 chore: Refactor manifest generation E2E tests to unit tests (#323) 2024-01-02 17:05:23 -06:00
Aaron 4f649fc872 feat(DX): Add ctrl+E/cmd+E shortcut to reload extension during development (#322) 2024-01-02 12:06:22 -06:00
dependabot[bot] 1af64aa02f chore(deps-dev): bump vitepress from 1.0.0-rc.31 to 1.0.0-rc.34 (#316)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-01 14:33:18 -06:00
dependabot[bot] c35acff259 chore(deps-dev): bump prettier from 3.1.0 to 3.1.1 (#318)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-01 14:31:32 -06:00
dependabot[bot] 995d4f8080 chore(deps-dev): bump tsx from 4.6.2 to 4.7.0 (#320)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-01 14:31:16 -06:00
GitHub Actions b7e2aa3d8f chore(release): v0.13.2 2024-01-01 16:06:12 +00:00
冯不游 e347f4529f feat: Add isolateEvents option to createContentScripUi (#313)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2024-01-01 10:01:33 -06:00
李志博 3d9683418d Upload a Chinese dubbing plugin for Youtube. (#314)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2024-01-01 09:45:44 -06:00
Aaron bdce363eed chore: Re-enable coverage (#312) 2023-12-29 17:28:35 -06:00
Aaron e7775f291a docs: Use example tags to automate relevant example lists (#311) 2023-12-29 17:25:46 -06:00
Aaron 59f6a28c65 chore: Upgrade template dependencies (#310) 2023-12-29 12:25:13 -06:00
Aaron f7534464bc chore: Update templates to ^0.13.0 (#309) 2023-12-29 12:14:05 -06:00
Aaron 01445fb1da docs: Add example for setting up custom panels/panes in devtools (#308) 2023-12-29 12:08:33 -06:00
Aaron Klinker 1360eb7c01 docs: Update content script entrypoint docs 2023-12-29 02:01:53 -06:00
Aaron Klinker c99a281169 docs: Update unlisted pages/scripts description 2023-12-29 01:54:39 -06:00
Aaron Klinker 76e63e24c0 docs: Remove duplicate entrypoints/ path 2023-12-29 01:51:26 -06:00
GitHub Actions f5ba1173da chore(release): v0.13.1 2023-12-29 07:39:32 +00:00
Aaron 12079e6ffc chore: Update scripts/build.ts show current build step in progress, not completed count (#306) 2023-12-29 01:21:18 -06:00
Aaron ff6740d429 fix: Ship vite/client types internally for proper resolution using PNPM (#304) 2023-12-29 01:21:05 -06:00
Aaron e6657cb533 fix(storage): Support multiple : characters in storage keys (#303) 2023-12-29 00:40:47 -06:00
Aaron Klinker 2ad099bf7e docs: General fixes and improvements 2023-12-27 12:32:59 -06:00
Aaron Klinker 6421ab34a4 docs: Reorder guide 2023-12-27 12:32:45 -06:00
Aaron Klinker 8919c4015a Update changelog 2023-12-27 11:07:57 -06:00
GitHub Actions 712f2ed4ac chore(release): v0.13.0 2023-12-27 17:05:45 +00:00
Aaron d1b9e5ded6 feat!: New wxt/storage APIs (#300)
BREAKING CHANGE: `wxt/storage` no longer relies on `unstorage`. Some `unstorage` APIs, like `prefixStorage`, have been removed, while others, like `snapshot`, are methods on the new `storage` object. Most of the standard usage remains the same.
2023-12-27 11:00:10 -06:00
GitHub Actions 86182044d1 chore(release): v0.12.5 2023-12-24 14:28:21 +00:00
Aaron e1f181356c fix: Correct import in dev-only, noop background (#298) 2023-12-24 08:24:53 -06:00
GitHub Actions 5f241cf407 chore(release): v0.12.4 2023-12-23 15:53:43 +00:00
Aaron b4a2cf4cb2 fix: Disable Vite CJS warnings (#296) 2023-12-23 09:35:43 -06:00
GitHub Actions aea137a3a1 chore(release): v0.12.3 2023-12-23 04:26:47 +00:00
Aaron ad0e8e4144 fix: Correctly mock webextension-polyfill for Vitest (#294) 2023-12-22 22:21:09 -06:00
GitHub Actions 7fd5752ef1 chore(release): v0.12.2 2023-12-19 17:48:24 +00:00
Aaron 1a8b7ff635 feat: Support PNPM without hoisting dependencies (#291) 2023-12-19 11:42:20 -06:00
GitHub Actions fe3ca0fcbd chore(release): v0.12.1 2023-12-16 17:24:16 +00:00
Aaron f49ee9f005 fix: Fix package.json lint errors (#290) 2023-12-16 11:18:16 -06:00
Aaron 961440c0ac fix: Upgrade @webext-core/match-patterns to 1.0.3 (#289) 2023-12-16 11:07:18 -06:00
Aaron 9b562b0ca8 chore: Upgrade templates to wxt@^0.12.0 (#285) 2023-12-13 16:21:52 -06:00
Aaron Klinker 7b2563b2dc Update changelog with breaking changes 2023-12-13 16:02:24 -06:00
GitHub Actions 63f507ecf5 chore(release): v0.12.0 2023-12-13 21:57:34 +00:00
Aaron 0a88955394 fix: Throw error when no entrypoints are found (#283) 2023-12-13 15:50:28 -06:00
Aaron Klinker 291d25b675 docs: Fix dead links 2023-12-13 15:50:09 -06:00
Aaron 6f902cc598 docs: Improve content script UI guide (#272) 2023-12-13 15:43:40 -06:00
Aaron fde488ac82 feat!: Add support for "main world" content scripts (#284)
BREAKING CHANGE: `defineContentScript` and `defineBackground` are now exported from `wxt/sandbox` instead of `wxt/client`. If you use auto-imports, no changes are required. If you have disabled auto-imports, you'll need to manually update your import statements.
2023-12-13 15:41:39 -06:00
Aaron 0873c24ad8 chore: Convert WXT CLI to an ESM binary (#279) 2023-12-12 14:44:04 -06:00
Aaron 7c02497148 fix: Only use type imports for Vite (#278) 2023-12-12 14:24:42 -06:00
GitHub Actions 25441a3b97 chore(release): v0.11.2 2023-12-12 19:24:47 +00:00
Aaron f1cf866fe1 fix: Improve duplicate entrypoint name detection and catch the error before loading their config (#276) 2023-12-12 13:22:09 -06:00
Aaron d92a126489 fix: Discover .js, .jsx, and .tsx unlisted scripts correctly (#274) 2023-12-12 12:24:38 -06:00
Aaron Klinker bcb93afe4e chore: Add missing navigation item 2023-12-09 23:37:29 -06:00
Aaron 21cf3642cb docs: Improve content script UI docs (#268) 2023-12-09 23:36:35 -06:00
Andrew Walsh 2ef28ec0c4 Update content-scripts.md (#266) 2023-12-09 23:33:59 -06:00
Aaron 59094c9af3 chore: Update sSolid template to vite 5 (#265) 2023-12-07 23:05:08 -06:00
GitHub Actions ba41691296 chore(release): v0.11.1 2023-12-07 21:41:45 +00:00
Aaron 8786aa91b3 feat: Add util for detecting URL changes in content scripts (#264) 2023-12-07 13:29:33 -06:00
Aaron 0623d10653 chore: Upgrade templates to wxt@^0.11.0 (#263) 2023-12-06 11:39:03 -06:00
Aaron Klinker 85174d4994 Fix changelog 2023-12-06 11:33:49 -06:00
117 changed files with 4494 additions and 2684 deletions
+14 -6
View File
@@ -12,14 +12,22 @@ A clear and concise description of what the bug is.
### To Reproduce
Upload a ZIP or share a link to a repo representing the minimal reproduction. **_If you don't upload a minimal reproduction, you bug report will be closed._**
Share minimal reproduction. Examples of a minimal reproduction:
Steps to reproduce the behavior:
- PR with a failing test case
- ZIP file containing a minimal WXT project (be sure to include your lockfile!!)
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
> **_⚠️ If you don't upload a minimal reproduction, your issue will be closed until a reproduction is added._**
>
> Why? https://antfu.me/posts/why-reproductions-are-required
Steps to reproduce the bug using the reproduction:
1. Install dependencies: `pnpm i`
2. Start dev mode: `pnpm dev`
3. Click this...
4. Do that...
5. Etc...
### Expected behavior
+7 -1
View File
@@ -13,6 +13,12 @@ jobs:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
- run: pnpm format:check
lint:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
- run: pnpm lint
type-check:
runs-on: ubuntu-22.04
steps:
@@ -54,7 +60,7 @@ jobs:
matrix:
template:
- react
# - solid
- solid
- svelte
- vanilla
- vue
+208 -1
View File
@@ -1,5 +1,212 @@
# Changelog
## v0.13.5
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.4...v0.13.5)
### 🩹 Fixes
- Strip path from `web_accessible_resources[0].matches` ([#332](https://github.com/wxt-dev/wxt/pull/332))
### 📖 Documentation
- Add section about customizing other browser options during development ([8683bd4](https://github.com/wxt-dev/wxt/commit/8683bd4))
### 🏡 Chore
- Update bug report template ([9a2cc18](https://github.com/wxt-dev/wxt/commit/9a2cc18))
## v0.13.4
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.3...v0.13.4)
### 🩹 Fixes
- Disable minification during development ([b7cdf15](https://github.com/wxt-dev/wxt/commit/b7cdf15))
### 🏡 Chore
- Use `const` instead of `let` ([2770974](https://github.com/wxt-dev/wxt/commit/2770974))
## v0.13.3
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.2...v0.13.3)
### 🚀 Enhancements
- **DX:** Add `ctrl+E`/`cmd+E` shortcut to reload extension during development ([#322](https://github.com/wxt-dev/wxt/pull/322))
### 🏡 Chore
- **deps-dev:** Bump tsx from 4.6.2 to 4.7.0 ([#320](https://github.com/wxt-dev/wxt/pull/320))
- **deps-dev:** Bump prettier from 3.1.0 to 3.1.1 ([#318](https://github.com/wxt-dev/wxt/pull/318))
- **deps-dev:** Bump vitepress from 1.0.0-rc.31 to 1.0.0-rc.34 ([#316](https://github.com/wxt-dev/wxt/pull/316))
- Refactor manifest generation E2E tests to unit tests ([#323](https://github.com/wxt-dev/wxt/pull/323))
## v0.13.2
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.1...v0.13.2)
### 🚀 Enhancements
- Add `isolateEvents` option to `createContentScripUi` ([#313](https://github.com/wxt-dev/wxt/pull/313))
### 📖 Documentation
- Remove duplicate `entrypoints/` path ([76e63e2](https://github.com/wxt-dev/wxt/commit/76e63e2))
- Update unlisted pages/scripts description ([c99a281](https://github.com/wxt-dev/wxt/commit/c99a281))
- Update content script entrypoint docs ([1360eb7](https://github.com/wxt-dev/wxt/commit/1360eb7))
- Add example for setting up custom panels/panes in devtools ([#308](https://github.com/wxt-dev/wxt/pull/308))
- Use example tags to automate relevant example lists ([#311](https://github.com/wxt-dev/wxt/pull/311))
### 🏡 Chore
- Update templates to `^0.13.0` ([#309](https://github.com/wxt-dev/wxt/pull/309))
- Upgrade template dependencies ([#310](https://github.com/wxt-dev/wxt/pull/310))
- Re-enable coverage ([#312](https://github.com/wxt-dev/wxt/pull/312))
### ❤️ Contributors
- 冯不游
## v0.13.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.0...v0.13.1)
### 🩹 Fixes
- **storage:** Support multiple `:` characters in storage keys ([#303](https://github.com/wxt-dev/wxt/pull/303))
- Ship `vite/client` types internally for proper resolution using PNPM ([#304](https://github.com/wxt-dev/wxt/pull/304))
### 📖 Documentation
- Reorder guide ([6421ab3](https://github.com/wxt-dev/wxt/commit/6421ab3))
- General fixes and improvements ([2ad099b](https://github.com/wxt-dev/wxt/commit/2ad099b))
### 🏡 Chore
- Update `scripts/build.ts` show current build step in progress, not completed count ([#306](https://github.com/wxt-dev/wxt/pull/306))
## v0.13.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.5...v0.13.0)
### 🚀 Enhancements
- ⚠️ New `wxt/storage` APIs ([#300](https://github.com/wxt-dev/wxt/pull/300))
#### ⚠️ Breaking Changes
- `wxt/storage` no longer relies on [`unstorage`](https://www.npmjs.com/package/unstorage). Some `unstorage` APIs, like `prefixStorage`, have been removed, while others, like `snapshot`, are methods on the new `storage` object. Most of the standard usage remains the same. See https://wxt.dev/guide/storage and https://wxt.dev/api/wxt/storage/ for more details ([#300](https://github.com/wxt-dev/wxt/pull/300))
## v0.12.5
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.4...v0.12.5)
### 🩹 Fixes
- Correct import in dev-only, noop background ([#298](https://github.com/wxt-dev/wxt/pull/298))
## v0.12.4
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.3...v0.12.4)
### 🩹 Fixes
- Disable Vite CJS warnings ([#296](https://github.com/wxt-dev/wxt/pull/296))
## v0.12.3
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.2...v0.12.3)
### 🩹 Fixes
- Correctly mock `webextension-polyfill` for Vitest ([#294](https://github.com/wxt-dev/wxt/pull/294))
## v0.12.2
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.1...v0.12.2)
### 🚀 Enhancements
- Support PNPM without hoisting dependencies ([#291](https://github.com/wxt-dev/wxt/pull/291))
## v0.12.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.0...v0.12.1)
### 🩹 Fixes
- Upgrade `@webext-core/match-patterns` to `1.0.3` ([#289](https://github.com/wxt-dev/wxt/pull/289))
- Fix `package.json` lint errors ([#290](https://github.com/wxt-dev/wxt/pull/290))
### 🏡 Chore
- Upgrade templates to `wxt@^0.12.0` ([#285](https://github.com/wxt-dev/wxt/pull/285))
## v0.12.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.11.2...v0.12.0)
### 🚀 Enhancements
- ⚠️ Add support for "main world" content scripts ([#284](https://github.com/wxt-dev/wxt/pull/284))
### 🩹 Fixes
- Only use type imports for Vite ([#278](https://github.com/wxt-dev/wxt/pull/278))
- Throw error when no entrypoints are found ([#283](https://github.com/wxt-dev/wxt/pull/283))
### 📖 Documentation
- Improve content script UI guide ([#272](https://github.com/wxt-dev/wxt/pull/272))
- Fix dead links ([291d25b](https://github.com/wxt-dev/wxt/commit/291d25b))
### 🏡 Chore
- Convert WXT CLI to an ESM binary ([#279](https://github.com/wxt-dev/wxt/pull/279))
#### ⚠️ Breaking Changes
`defineContentScript` and `defineBackground` are now exported from `wxt/sandbox` instead of `wxt/client`. ([#284](https://github.com/wxt-dev/wxt/pull/284))
- If you use auto-imports, no changes are required.
- If you have disabled auto-imports, you'll need to manually update your import statements:
```diff
- import { defineBackground, defineContentScript } from 'wxt/client';
+ import { defineBackground, defineContentScript } from 'wxt/sandbox';
```
## v0.11.2
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.11.1...v0.11.2)
### 🩹 Fixes
- Discover `.js`, `.jsx`, and `.tsx` unlisted scripts correctly ([#274](https://github.com/wxt-dev/wxt/pull/274))
- Improve duplicate entrypoint name detection and catch the error before loading their config ([#276](https://github.com/wxt-dev/wxt/pull/276))
### 📖 Documentation
- Improve content script UI docs ([#268](https://github.com/wxt-dev/wxt/pull/268))
### 🏡 Chore
- Update sSolid template to vite 5 ([#265](https://github.com/wxt-dev/wxt/pull/265))
- Add missing navigation item ([bcb93af](https://github.com/wxt-dev/wxt/commit/bcb93af))
## v0.11.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.11.0...v0.11.1)
### 🚀 Enhancements
- Add util for detecting URL changes in content scripts ([#264](https://github.com/wxt-dev/wxt/pull/264))
### 🏡 Chore
- Upgrade templates to `wxt@^0.11.0` ([#263](https://github.com/wxt-dev/wxt/pull/263))
## v0.11.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.10.4...v0.11.0)
@@ -19,7 +226,7 @@
#### ⚠️ Breaking Changes
- ⚠️ Vite 5 support ([#261](https://github.com/wxt-dev/wxt/pull/261))
- You will need to update any other Vite plugins to a version that supports Vite 5 ([#261](https://github.com/wxt-dev/wxt/pull/261))
### ❤️ Contributors
+2 -3
View File
@@ -9,9 +9,8 @@
<a href="https://www.npmjs.com/package/wxt" target="_blank"><img alt="npm" src="https://img.shields.io/npm/dm/wxt?labelColor=black&color=%234fa048"></a>
<span> </span>
<a href="https://github.com/wxt-dev/wxt/blob/main/LICENSE" target="_blank"><img alt="NPM" src="https://img.shields.io/npm/l/wxt?labelColor=black&color=%234fa048"></a>
<!-- Hide code coverage while it's broken -->
<!-- <span> </span>
<a href="https://codecov.io/github/wxt-dev/wxt" target="_blank"><img alt="Codecov" src="https://img.shields.io/codecov/c/github/wxt-dev/wxt?labelColor=black&color=%234fa048"></a> -->
<span> </span>
<a href="https://codecov.io/github/wxt-dev/wxt" target="_blank"><img alt="Codecov" src="https://img.shields.io/codecov/c/github/wxt-dev/wxt?labelColor=black&color=%234fa048"></a>
</p>
<p align="center">
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env node
require('../dist/cli.cjs');
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../dist/cli.js';
+1 -3
View File
@@ -19,13 +19,11 @@
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"vitest": "^0.34.6",
"webextension-polyfill": "^0.10.0"
"vitest": "^1.1.0"
},
"devDependencies": {
"@types/react": "^18.2.34",
"@types/react-dom": "^18.2.14",
"@types/webextension-polyfill": "^0.10.5",
"sass": "^1.69.5",
"wxt": "workspace:*"
}
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import background from '../background';
browser.i18n.getMessage = () => 'fake-message';
@@ -7,6 +7,10 @@ const logMock = vi.fn();
console.log = logMock;
describe('Background Entrypoint', () => {
beforeEach(() => {
fakeBrowser.reset();
});
it("should log the extenion's runtime ID", () => {
const id = 'some-id';
fakeBrowser.runtime.id = id;
@@ -15,4 +19,11 @@ describe('Background Entrypoint', () => {
expect(logMock).toBeCalledWith(id);
});
it('should set the start time in storage', async () => {
background.main();
await new Promise((res) => setTimeout(res));
expect(await storage.getItem('session:startTime')).toBeDefined();
});
});
@@ -0,0 +1,11 @@
export default defineContentScript({
// Site that uses HTML5 history
matches: ['*://*.crunchyroll.com/*'],
main(ctx) {
ctx.addEventListener(window, 'wxt:locationchange', ({ newUrl, oldUrl }) => {
console.log('Location changed:', newUrl.href, oldUrl.href);
});
console.log('Watching for location change...');
},
});
@@ -0,0 +1,8 @@
export default defineContentScript({
matches: ['*://*/*'],
world: 'MAIN',
main() {
console.log(`Hello from ${location.hostname}!`);
},
});
@@ -0,0 +1,34 @@
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue';
const props = defineProps<{
tag?: string;
}>();
const examples = ref();
onMounted(async () => {
const res = await fetch(
'https://raw.githubusercontent.com/wxt-dev/wxt-examples/main/examples.json',
);
examples.value = await res.json();
});
const filteredExamples = computed(() => {
if (props.tag == null) return examples.value;
return examples.value.filter((example) => {
return example.tags?.includes(props.tag);
});
});
</script>
<template>
<ul>
<li v-if="examples == null">Loading...</li>
<template v-else>
<li v-for="example of filteredExamples">
<a :href="example.url" target="_blank">{{ example.name }}</a>
</li>
</template>
</ul>
</template>
@@ -5,13 +5,18 @@ import useListExtensionDetails, {
} from '../composables/useListExtensionDetails';
// Add extension IDs here. Order doesn't matter, will be sorted by weekly active users
// During the transition from chrome.google.com/webstore to
// chromewebstore.google.com, queue.wxt.dev might return null for your
// extension. If it does, use "<slug>/<id>" instead of just the ID. The slug
// can be retrieved from the URL of the item on chromewebstore.google.com
const chromeExtensionIds = [
'ocfdgncpifmegplaglcnglhioflaimkd', // GitHub: Better Line Counts
'mgmdkjcljneegjfajchedjpdhbadklcf', // Anime Skip Player
'bfbnagnphiehemkdgmmficmjfddgfhpl', // UltraWideo
'elfaihghhjjoknimpccccmkioofjjfkf', // StayFree - Website Blocker & Web Analytics
'okifoaikfmpfcamplcfjkpdnhfodpkil', // Doozy: Ai Made Easy
'lknmjhcajhfbbglglccadlfdjbaiifig' // tl;dv - Record, Transcribe & ChatGPT for Google Meet
'lknmjhcajhfbbglglccadlfdjbaiifig', // tl;dv - Record, Transcribe & ChatGPT for Google Meet
'youtube中文配音/oglffgiaiekgeicdgkdlnlkhliajdlja', // Youtube中文配音
];
const { data } = useListExtensionDetails(chromeExtensionIds);
@@ -24,6 +29,7 @@ const sortedExtensions = computed(() => {
// Sort based on the user count weighted by the rating
sortKey: ((item.rating ?? 5) / 5) * item.weeklyActiveUsers,
}))
.filter((item) => !!item)
.sort((l, r) => r.sortKey - l.sortKey);
});
+6 -5
View File
@@ -84,17 +84,18 @@ export default defineConfig({
{ text: 'Installation', link: '/guide/installation.md' },
{ text: 'Configuration', link: '/guide/configuration.md' },
{ text: 'Entrypoints', link: '/guide/entrypoints.md' },
{ text: 'Assets', link: '/guide/assets.md' },
{ text: 'Multiple Browsers', link: '/guide/multiple-browsers.md' },
{ text: 'Publishing', link: '/guide/publishing.md' },
{ text: 'Auto-imports', link: '/guide/auto-imports.md' },
{ text: 'Manifest.json', link: '/guide/manifest.md' },
{ text: 'Extension APIs', link: '/guide/extension-apis.md' },
{ text: 'Storage', link: '/guide/storage.md' },
{ text: 'Assets', link: '/guide/assets.md' },
{ text: 'Content Script UI', link: '/guide/content-script-ui.md' },
{ text: 'Multiple Browsers', link: '/guide/multiple-browsers.md' },
{ text: 'Auto-imports', link: '/guide/auto-imports.md' },
{ text: 'Vite', link: '/guide/vite.md' },
{ text: 'Remote Code', link: '/guide/remote-code.md' },
{ text: 'Publishing', link: '/guide/publishing.md' },
{ text: 'Development', link: '/guide/development.md' },
{ text: 'Testing', link: '/guide/testing.md' },
{ text: 'Vite', link: '/guide/vite.md' },
],
},
{
+2
View File
@@ -2,6 +2,7 @@ import DefaultTheme from 'vitepress/theme';
import Icon from '../components/Icon.vue';
import EntrypointPatterns from '../components/EntrypointPatterns.vue';
import UsingWxtSection from '../components/UsingWxtSection.vue';
import ExampleList from '../components/ExampleList.vue';
import './custom.css';
export default {
@@ -10,5 +11,6 @@ export default {
ctx.app.component('Icon', Icon);
ctx.app.component('EntrypointPatterns', EntrypointPatterns);
ctx.app.component('UsingWxtSection', UsingWxtSection);
ctx.app.component('ExampleList', ExampleList);
},
};
+3 -197
View File
@@ -20,7 +20,7 @@ When creating content script entrypoints, they are automatically included in the
```ts
export default defineContentScript({
// Set manifest options
matches: ['*://google.com/*', '*://duckduckgo.com/*'],
matches: string[],
excludeMatches: undefined | [],
includeGlobs: undefined | [],
excludeGlobs: undefined | [],
@@ -90,7 +90,7 @@ To include CSS with your content script, import the CSS file at the top of your
```
<srcDir>
<srcDir>/
└─ entrypoints/
└─ overlay.content/
├─ index.ts
@@ -138,198 +138,4 @@ export default defineContentScript({
});
```
## UI
WXT provides a utility function, `createContentScriptUi` to simplify mounting a UI from a content script. Internally, it uses the `ShadowRoot` API to isolate your CSS from the webpages.
`createContentScriptUi` requires a `ContentScriptContext` so that when the context is invalidated, the UI is automatically removed from the webpage.
:::details When to use `createContentScriptUi`
You should only use `createContentScriptUi` if you want your UI's styles isolated from the webpages. If you want to create a more "integrated" UI that uses the page's styles, you can just use the regular JS API's to append your UI to the page.
```ts
const ui = document.createElement('div');
const anchor = document.querySelector('#anchor-selector');
anchor.append(ui);
```
You can try out [`createContentScriptIframe`](#createcontentscriptiframe) as an alternative solution
:::
### Usage
To use `createContentScriptUi`, follow these steps:
1. Import your CSS file at the top of your content script
2. Set `cssInjectionMode: "ui"` inside `defineContentScript`
3. Call `createContentScriptUi`
4. Call `mount` to add the UI to the webpage
Here's a basic example:
```ts
// entrypoints/ui.content/index.ts
import './style.css';
export default defineContentScript({
// ...
cssInjectionMode: 'ui',
async main(ctx) {
const ui = await createContentScriptUi(ctx, {
name: 'example-ui',
type: 'inline',
anchor: '#some-element',
append: 'after',
mount(container) {
// Mount UI inside `container`...
},
});
// You must call `mount` to add the UI to the page.
ui.mount();
},
});
```
If you're using a frontend framework, you'll also need to include an `onRemoved` callback:
:::code-group
```ts [Vue]
import { createApp } from 'vue';
createContentScriptUi(ctx, {
// ...
mount(container) {
// Create a new app and mount it inside the container
const app = createApp(...);
app.mount(container);
return app;
},
onRemove(app) {
// When the UI is removed from the DOM, call unmount to stop the app
app.unmount();
},
});
```
```ts [React]
import ReactDOM from 'react-dom/client';
createContentScriptUi(ctx, {
// ...
mount(container) {
// Create a root using the container and render your app
const root = ReactDOM.createRoot(container);
root.render(...);
return root;
},
onRemove(root) {
// When the UI is removed from the DOM, call unmount to stop the app
root.unmount();
},
});
```
```ts [Svelte]
import App from './App.svelte';
createContentScriptUi(ctx, {
// ...
mount(container) {
// Mount your app component inside the container
return new App({
target: container,
});
},
onRemove(app) {
// When the UI is removed from the DOM, call $destroy to stop the app
app.$destroy();
},
});
```
```ts [Solid]
import { render } from 'solid-js/web';
createContentScriptUi(ctx, {
// ...
mount(container) {
// Render your app component into the container
return render(() => ..., container)
},
onRemove(unmount) {
// When the UI is removed from the DOM, call unmount to stop the app
unmount();
},
});
```
:::
### `anchor`
The anchor dictates where the UI will be mounted.
### `append`
Customize where the UI get's appended to the DOM, relative to the `anchor` element.
### `type`
There are 3 types of UI's you can mount.
- `inline`: Shows up inline based on the `anchor` and `append` options
- `overlay`: Shows up inline, but styled to be 0px by 0px, with overflow visible. This causes the UI to overlay on top of the webpage's content
- `modal`: A fullscreen overlay that covers the entire screen, regardless of where it's anchored.
> TODO: Add visualization of the different UI types.
### Overlay `alignment`
Because the overlay UI type results in a 0px by 0px container being added to the webpage, the `alignment` option allows you to configure which corner of your UI is aligned with the 0x0 element.
> TODO: Add visualization of the different alignments.
## IFrame
WXT provides a utility function, `createContentScriptIframe` to simplify mounting a UI from a content script. It creates an iframe to an unlisted HTML page. Unlike `createContentScriptUi`, this API support HMR.
`createContentScriptIframe` requires a `ContentScriptContext` so that when the context is invalidated, the UI is automatically removed from the webpage.
### Usage
To use `createContentScriptIframe`, follow these steps:
1. Create an unlisted HTML page that will be loaded into your iframe
1. Add unlisted page to the manifest's `web_accessible_resouces`
1. Call `createContentScriptUi`
1. Call `mount` to add the UI to the webpage
Here's a basic example:
```ts
export default defineContentScript({
// ...
async main(ctx) {
const ui = await createContentScriptIframe(ctx, {
page: '/your-unlisted-page.html',
type: 'inline',
anchor: '#some-element',
append: 'after',
});
// You must call `mount` to add the UI to the page.
ui.mount();
},
});
```
The options, other than `page`, are the same as [`createContentScriptUi`](#anchor).
### `page`
The HTML page you want to load inside the iframe. This string will be passed into `browser.runtime.getURL` to resolve the full path of to your HTML page.
See [Content Script UI](/guide/content-script-ui) for more info on creating UIs and including CSS in content scripts.
+10 -3
View File
@@ -13,15 +13,12 @@
## Definition
Plain old HTML file.
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
@@ -31,3 +28,13 @@ Plain old HTML file.
</body>
</html>
```
## Adding UI Elements
Chrome extensions allow you to add panels and side panes to the devtools window.
![DevTools window showing Elements panel and Styles sidebar pane.](https://developer.chrome.com/static/docs/extensions/how-to/devtools/extend-devtools/image/devtools-window-showing-e-9051f7f0347cd_1920.png)
See the WXT's examples for a full walkthrough of extending the devtools window:
<ExampleList tag="devtools" />
+4 -4
View File
@@ -10,10 +10,10 @@ Firefox does not support sandboxed pages.
<EntrypointPatterns
:patterns="[
['entrypoints/sandbox.html', 'sandbox.html'],
['entrypoints/sandbox/index.html', 'sandbox.html'],
['entrypoints/<name>.sandbox.html', '<name>.html` '],
['entrypoints/<name>.sandbox/index.html', '<name>.html` '],
['sandbox.html', 'sandbox.html'],
['sandbox/index.html', 'sandbox.html'],
['<name>.sandbox.html', '<name>.html` '],
['<name>.sandbox/index.html', '<name>.html` '],
]"
/>
+4 -4
View File
@@ -10,10 +10,10 @@ Chrome added support for sidepanels in Manifest V3, they are not available in Ma
<EntrypointPatterns
:patterns="[
['entrypoints/sidepanel.html', 'sidepanel.html'],
['entrypoints/sidepanel/index.html', 'sidepanel.html'],
['entrypoints/<name>.sidepanel.html', '<name>.html` '],
['entrypoints/<name>.sidepanel/index.html', '<name>.html` '],
['sidepanel.html', 'sidepanel.html'],
['sidepanel/index.html', 'sidepanel.html'],
['<name>.sidepanel.html', '<name>.html` '],
['<name>.sidepanel/index.html', '<name>.html` '],
]"
/>
+1 -1
View File
@@ -1,6 +1,6 @@
# Unlisted Pages
HTML pages that are built by Vite, but are not included in the manifest.
HTML pages that are bundled and shipped with the extension, but are not included in the manifest.
### Examples
+1 -1
View File
@@ -1,6 +1,6 @@
# Unlisted Scripts
TypeScript files that are built, but are not included in the manifest.
TypeScript files that are bundled and shipped with the extension, but are not included in the manifest.
You are responsible for loading/running these scripts where needed.
+1 -21
View File
@@ -2,26 +2,6 @@
Simple walkthroughs to accomplish common tasks or patterns with WXT.
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
const examples = ref()
onMounted(async () => {
const res = await fetch("https://raw.githubusercontent.com/wxt-dev/wxt-examples/main/examples.json");
examples.value = await res.json();
})
</script>
<ul>
<li v-if="examples == null">
Loading...
</li>
<template v-else>
<li v-for="example of examples">
<a :href="example.url" target="_blank">{{ example.name }}</a>
</li>
</template>
</ul>
<ExampleList />
> Full code available at [`wxt-dev/wxt-examples`](https://github.com/wxt-dev/wxt-examples)
+4 -4
View File
@@ -11,13 +11,13 @@ To setup your test environment for auto-imports, see [Testing](/guide/testing).
Some WXT APIs can be used without importing them:
- [`browser`](/api/wxt/browser/variables/browser) from `wxt/browser`, a small wrapper around `webextension-polyfill`
- [`defineContentScript`](/api/wxt/client/functions/defineContentScript) from `wxt/client`
- [`defineBackground`](/api/wxt/client/functions/defineBackground) from `wxt/client`
- [`createContentScriptUi`](/api/wxt/client/functions/createContentScriptUi) from `wxt/client`
- [`defineContentScript`](/api/wxt/sandbox/functions/defineContentScript) from `wxt/sandbox`
- [`defineBackground`](/api/wxt/sandbox/functions/defineBackground) from `wxt/sandbox`
- [`defineUnlistedScript`](/api/wxt/sandbox/functions/defineUnlistedScript) from `wxt/sandbox`
- [`createContentScriptUi`](/api/wxt/client/functions/createContentScriptUi) from `wxt/client`
- [`fakeBrowser`](/api/wxt/testing/variables/fakeBrowser) from `wxt/testing`
And more. All `wxt/*` APIs can be used without imports.
And more!
## Project Auto-imports
+10 -12
View File
@@ -51,12 +51,6 @@ export default defineConfig({
});
```
## Vite Config
[Vite](https://vitejs.dev/) is the bundler used to build each entrypoint of your extension. Vite can be configured via the `vite` option.
See [Vite's documentation](https://vitejs.dev/config/) for configuring the bundler.
## Frontend Frameworks
Adding a framework like Vue, React, or Svelte is easy!
@@ -70,9 +64,9 @@ import { defineConfig } from 'wxt';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
vite: {
vite: () => ({
plugins: [vue()],
},
}),
});
```
@@ -81,9 +75,9 @@ import { defineConfig } from 'wxt';
import react from '@vitejs/plugin-react';
export default defineConfig({
vite: {
vite: () => ({
plugins: [react()],
},
}),
});
```
@@ -92,10 +86,14 @@ import { defineConfig } from 'wxt';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
vite: {
vite: () => ({
plugins: [svelte()],
},
}),
});
```
:::
:::info
Internally, WXT runs `vite build` multiple times during a single build. It can't share the same instance of some plugins between each build, so a function is used to return completely new instances of each plugin for each build.
:::
+326
View File
@@ -0,0 +1,326 @@
# Content Script UI
There are three ways to mount a UI inside a content script:
[[toc]]
Each has their own set of advantages and disadvantages.
| Method | Isolated Styles | Isolated Events | HMR | Use page's context |
| ---------- | :-------------: | :-----------------: | :-: | :----------------: |
| Integrated | ❌ | ❌ | ❌ | ✅ |
| ShadowRoot | ✅ | ✅ (off by default) | ❌ | ✅ |
| IFrame | ✅ | ✅ | ✅ | ❌ |
## Integrated
Integrated content script UIs are injected alongside the content of a page. This means that they are affected by CSS on that page.
You can control how CSS is injected for an integrated content script UI with the [`cssInjectionMode`](/api/wxt/interfaces/ContentScriptBaseDefinition#cssinjectionmode) property.
:::code-group
```ts [Vanilla]
// entrypoints/example-ui.content.ts
export default defineContentScript({
main(ctx) {
// Create the UI container
const container = document.createElement('div');
// Add UI container to the page
const anchor = document.querySelector('#anchor');
anchore.append(container);
// Remove UI container when invalidated
ctx.onInvalidated(() => {
container.remove();
});
},
});
```
```ts [Vue]
// entrypoints/example-ui.content/index.ts
import { createApp } from 'vue';
export default defineContentScript({
main(ctx) {
// Create the UI container
const container = document.createElement('div');
// Create the app and mount it to the UI container
const app = createApp(...);
app.mount(container);
// Add UI container to the page
const anchor = document.querySelector('#anchor');
anchore.append(container);
// Unmount the app and remove UI container when invalidated
ctx.onInvalidated(() => {
app.unmount();
container.remove();
});
},
});
```
```tsx [React]
// entrypoints/example-ui.content/index.tsx
import ReactDOM from 'react-dom/client';
export default defineContentScript({
main(ctx) {
// Create the UI container
const container = document.createElement('div');
// Create a root on the UI container and render a component
const root = ReactDOM.createRoot(container);
root.render(...);
// Add UI container to the page
const anchor = document.querySelector('#anchor');
anchore.append(container);
// Unmount the root and remove UI container when invalidated
ctx.onInvalidated(() => {
root.unmount();
container.remove();
});
},
});
```
```ts [Svelete]
// entrypoints/example-ui.content/index.ts
import App from './App.svelte';
export default defineContentScript({
main(ctx) {
// Create the UI container
const container = document.createElement('div');
// Create the Svelte app inside the UI container
const app = new App({
target: ui,
});
// Add UI container to the page
const anchor = document.querySelector('#anchor');
anchore.append(container);
// Destroy the app and remove UI container when invalidated
ctx.onInvalidated(() => {
app.$destroy();
container.remove();
});
},
});
```
```tsx [Solid]
// entrypoints/example-ui.content/index.ts
import { render } from 'solid-js/web';
export default defineContentScript({
main(ctx) {
// Create the UI container
const container = document.createElement('div');
// Render your app to the UI container
const unmount = render(() => ..., container)
// Add UI container to the page
const anchor = document.querySelector('#anchor');
anchore.append(container);
// Unmount the app and remove UI container when invalidated
ctx.onInvalidated(() => {
unmount();
container.remove();
});
},
});
```
:::
## ShadowRoot
Often in web extensions, you don't want your content script's CSS affecting the page, or vise-versa. The [`ShadowRoot`](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot) API is ideal for this.
WXT provides a helper function, [`createContentScriptUi`](/api/wxt/client/functions/createContentScriptUi), that abstracts all the `ShadowRoot` setup away, making it easy to create UIs with isolated CSS. It also supports an optional `isolateEvents` parameter to further isolate user interactions.
To use `createContentScriptUi`, follow these steps:
1. Import your CSS file at the top of your content script
2. Set [`cssInjectionMode: "ui"`](/api/wxt/interfaces/ContentScriptBaseDefinition#cssinjectionmode) inside `defineContentScript`
3. Define your UI with `createContentScriptUi()`
4. Mount the UI so it is visible to users
```ts
// 1. Import the style
import './style.css';
export default defineContentScript({
// 2. Set cssInjectionMode
cssInjectionMode: 'ui',
async main(ctx) {
// 3. Define your UI
const ui = await createContentScriptUi(ctx, {
name: 'example-ui',
anchor: '#anchor',
type: 'inline',
mount(container) {
// Define how your UI will be mounted inside the container
const app = document.createElement('p');
app.textContent = 'Hello world!';
container.append(app);
},
});
// 4. Mount the UI
ui.mount();
},
});
```
> `createContentScriptUi` will automatically remove the UI from the page when the content script is invalidated.
See the [API Reference](/api/wxt/client/functions/createContentScriptUi) for the complete list of options.
:::info TailwindCSS
`createContentScriptUi` supports TailwindCSS out of the box! When importing the styles, just import the main CSS file containing the `@tailwind` directives, and everything will just work :+1:.
:::
When using a frontend framework for your UI, you'll need to unmount the app when the UI is removed. This is accomplished by returning an app reference from the `mount` option and by passing in a custom `onRemoved` option:
:::code-group
```ts [Vue]
import { createApp } from 'vue';
const ui = createContentScriptUi(ctx, {
// ...
mount(container) {
const app = createApp(App);
app.mount(container);
return app;
},
onRemove(app) {
app.unmount();
},
});
```
```tsx [React]
import ReactDOM from 'react-dom/client';
const ui = createContentScriptUi(ctx, {
// ...
mount(container) {
const root = ReactDOM.createRoot(container);
root.render(...);
return root;
},
onRemove(root) {
root.unmount();
},
});
```
```ts [Svelte]
import App from './App.svelte';
const ui = createContentScriptUi(ctx, {
// ...
mount(container) {
return new App({ target: container });
},
onRemove(app) {
app.$destry();
},
});
```
```tsx [Solid]
import { render } from 'solid-js/web';
const ui = createContentScriptUi(ctx, {
// ...
mount(container) {
return render(() => ..., container);
},
onRemove(unmount) {
unmount();
},
});
```
:::
:::warning
The `mount(container)` and `onRemove(app)` options passed into `createContentScriptUi` **_are different from_** the `ui.mount()` and `ui.remove()` functions available on the returned UI object.
You don't need to pass anything into `ui.mount()` and `ui.remove()` because **_you already defined how and where the UI will be mounted_** in the options passed into `createContentScriptUi`.
:::
## IFrame
If you don't need to run your UI in the same frame as the content script, you can use an IFrame to host your UI instead. Since an IFrame just hosts an HTML page, **_HMR is supported_**.
WXT provides a helper function, [`createContentScriptIframe`](/api/wxt/client/functions/createContentScriptUi), which simplifies setting up the IFrame.
1. Create an HTML page that will be loaded into your IFrame
```html
<!-- entrypoints/example-iframe.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Your Content Script IFrame</title>
</head>
<body>
<!-- ... -->
</body>
</html>
```
1. Add the page to the manifest's `web_accessible_resouces`
```ts
// wxt.config.ts
export default defineConfig({
manifest: {
web_accessible_resources: [
{
resources: ['example-iframe.html'],
matches: [...],
},
],
},
});
```
1. Create and mount the IFrame
```ts
export default defineContentScript({
// ...
async main(ctx) {
// Define the UI
const ui = await createContentScriptIframe(ctx, {
page: '/example-iframe.html',
anchor: '#anchor',
type: 'inline',
});
// Add styles to the iframe like width
ui.iframe.width = 123;
// Show UI to user
ui.mount();
},
});
```
See the [API Reference](/api/wxt/client/functions/createContentScriptUi) for the complete list of options.
+29
View File
@@ -39,6 +39,8 @@ export default defineRunnerConfig({
:::
### Browser Binaries
`web-ext`'s browser discovery is very limitted. By default, it only guesses at where Chrome and Firefox are installed. If you've customized your install locations, you may need to tell `web-ext` where the binaries/executables are located using the [`binaries` option](/api/wxt/interfaces/ExtensionRunnerConfig#binaries). For other Chromium based browsers, like Edge or Opera, you'll need to explicitly list them in the `binaries` option as well, otherwise they will open in Chrome by default.
```ts
@@ -57,3 +59,30 @@ export default defineRunnerConfig({
:::tip
When configuring browser binaries, it's helpful to put them in `~/web-ext.config.ts` instead of the project directory's `web-ext.config.ts` file. When placed in your home directory (`~/`), this config will be used by all WXT projects, so you only need to configure the binaries once.
:::
### Other options
You can customize other options as well, like startup URLs, profiles, or additional command line arguments:
```ts
// web-ext.config.ts
import { defineRunnerConfig } from 'wxt';
export default defineRunnerConfig({
startUrls: ['https://google.com', 'https://duckduckgo.com'],
chromiumProfile: '/path/to/profile/to/use',
chromiumArgs: ['--window-size=400x300'],
});
```
For a full list of options, see the [API Reference](/api/wxt/interfaces/ExtensionRunnerConfig).
## Reload the Extension
Normally, to manually reload an extension, you have to visit `chrome://extensions` and click the reload button for your extension.
When running `wxt` command to start the dev server, WXT adds a keyboard shortcut, `ctrl+E` for Windows/Linux and `cmd+E` for Mac, that reloads the extension when pressed, without visiting `chrome://extensions`.
:::note
This shortcut is only available during development, and is not be added to your extension when running `wxt build` or `wxt-zip`.
:::
+10 -3
View File
@@ -1,12 +1,13 @@
# Manifest.json
The manifest.json is generated at build-time based on files in your `entrypoints` directory and your `wxt.config.ts`.
The manifest.json is generated at build-time based on files in the `entrypoints/` directory and `wxt.config.ts`.
## Configuration
While entrypoints are generated and added to the manifest at build-time, you can customize or add to your `manifest.json` in the config file.
```ts
// wxt.config.tsentrypoint of your extension
import { defineConfig } from 'wxt';
export default defineConfig({
@@ -25,11 +26,13 @@ If not provided via the `manifest` config, the [manifest's `name`](https://devel
## `version` and `version_name`
The [manifest's `version` and `version_name`](https://developer.chrome.com/docs/extensions/mv3/manifest/version/) fields are based on your package.json's `version` property.
The [manifest's `version` and `version_name`](https://developer.chrome.com/docs/extensions/mv3/manifest/version/) properties are based on the `version` field listed in your `package.json` or `wxt.config.ts`.
- `version_name` is the exact string listed in your package.json
- `version_name` is the exact string listed in your `package.json` or `wxt.config.ts` file
- `version` is the string cleaned up, with any invalid suffixes removed
If a version is not found, a warning is logged and the version defaults to `"0.0.0"`.
### Example
```json
@@ -118,3 +121,7 @@ export default defineConfig({
},
});
```
See the official localization examples for more details:
<ExampleList tag="i18n" />
+1 -1
View File
@@ -4,7 +4,7 @@ WXT offers several utilities that simplify the publishing process.
## First Time Publishing
If you're publishing an extension to a store for the first time, it's recommended that you manually navigate the process. Each store has unique steps and requirements that you need to familiarize yourself with.
If you're publishing an extension to a store for the first time, you must manually navigate the process. Each store has unique steps and requirements that you need to familiarize yourself with.
Each store requires that a ZIP file be uploaded. You can generate these using the `wxt zip` command:
+246 -28
View File
@@ -1,36 +1,254 @@
# Storage API
WXT's storage API is powered by `unstorage`. See [their docs](https://unstorage.unjs.io/usage#usage-1) for more details.
## Overview
:::code-group
```ts [native]
const { installDate } = await browser.storage.local.get('installDate');
await browser.storage.local.set({ key: 'value' });
```
```ts [wxt/browser]
const installDate = await storage.get('local:installDate');
await storage.setItem('key', 'value');
```
:::
Use the `"local:"`, `"session:"`, `"sync:"`, and `"managed:"` prefixes to specify which storage area to use.
## Customization
WXT also provides a driver for `unstorage`. To customize the `storage` object's setup, like removing the prefixes and using a single storage area, you can create your own storage:
WXT provides a simplified API to replace the `browser.storage.*` APIs. Use the `storage` auto-import from `wxt/storage` or import it manually to get started:
```ts
// storage.ts
export default createStorage({
driver: webExtensionDriver({ storageArea: 'local' }),
import { storage } from 'wxt/storage';
```
[[toc]]
## Basic Usage
All storage keys must be prefixed by their storage area.
```ts
// ❌ This will throw an error
await storage.getItem('installDate');
// ✅ This is good
await storage.getItem('local:installDate');
```
You can use `local:`, `session:`, `sync:`, or `managed:`.
If you use TypeScript, you can add a type parameter to most methods to specify the expected type of the key's value:
```ts
await storage.getItem<number>('local:installDate');
await storage.watch<number>(
'local:installDate',
(newInstallDate, oldInstallDate) => {
// ...
},
);
await storage.getMeta<{ v: number }>('local:installDate');
```
## Watchers
To listen for storage changes, use the `storage.watch` function. It lets you setup a listener for a single key:
```ts
const unwatch = storage.watch<number>('local:counter', (newCount, oldCount) => {
console.log('Count changed:', { newCount, oldCount });
});
```
:::note
`wxt/browser` re-exports all of `unstorage`, which is where `createStorage` comes from.
To remove the listener, call the returned `unwatch` function:
```ts
const unwatch = storage.watch(...);
// Some time later...
unwatch();
```
## Metadata
`wxt/storage` also supports setting metadata for keys, stored at `key + "$"`. Metadata is a collection of properties associated with a key. It might be a version number, last modified date, etc.
[Other than versioning](#versioning-and-migrations), you are responsible for managing a field's metadata:
```ts
await Promise.all([
storage.setItem('local:preference', true),
storage.setMeta('local:preference', { lastModified: Date.now() }),
]);
```
When setting different properties of metadata from multiple calls, the properties are combined instead of overwritten:
```ts
await storage.setMeta('local:preference', { lastModified: Date.now() });
await storage.setMeta('local:preference', { v: 2 });
await storage.getMeta('local:preference'); // { v: 2, lastModified: 1703690746007 }
```
You can remove all metadata associated with a key, or just specific properties:
```ts
// Remove all properties
await storage.removeMeta('local:preference');
// Remove one property
await storage.removeMeta('local:preference', 'lastModified');
// Remove multiple properties
await storage.removeMeta('local:preference', ['lastModified', 'v']);
```
## Defining Storage Items
Writing the key and type parameter for the same key over and over again can be annoying. As an alternative, you can use `storage.defineItem` to create a "storage item".
Storage items contain the same APIs as the `storage` variable, but you can configure its type, default value, and more in a single place:
```ts
// utils/storage.ts
const showChangelogOnUpdate = storage.defineItem<boolean>(
'local:showChangelogOnUpdate',
{
defaultValue: true,
},
);
```
Now, instead of using the `storage` variable, you can use the helper functions on the storage item you created:
```ts
await showChangelogOnUpdate.getValue();
await showChangelogOnUpdate.setValue(false);
await showChangelogOnUpdate.removeValue();
const unwatch = showChangelogOnUpdate.watch(() => {
// ...
});
```
### Versioning and Migrations
You can add versioning to storage items if you expect them to grow or change over time. When defining the first version of an item, start with version 1.
For example, consider a storage item that stores a list of websites that are ignored by an extension.
:::code-group
```ts [v1]
type IgnoredWebsiteV1 = string;
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV1[]>(
'local:ignoredWebsites',
{
defaultValue: [],
version: 1,
},
);
```
<!-- prettier-ignore -->
```ts [v2]
import { nanoid } from 'nanoid'; // [!code ++]
type IgnoredWebsiteV1 = string;
interface IgnoredWebsiteV2 { // [!code ++]
id: string; // [!code ++]
website: string; // [!code ++]
} // [!code ++]
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV1[]>( // [!code --]
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV2[]>( // [!code ++]
'local:ignoredWebsites',
{
defaultValue: [],
version: 1, // [!code --]
version: 2, // [!code ++]
migrations: { // [!code ++]
// Ran when migrating from v1 to v2 // [!code ++]
2: (websites: IgnoredWebsiteV1[]): IgnoredWebsiteV2[] => { // [!code ++]
return websites.map((website) => ({ id: nanoid(), website })); // [!code ++]
}, // [!code ++]
}, // [!code ++]
},
);
```
<!-- prettier-ignore -->
```ts [v3]
import { nanoid } from 'nanoid';
type IgnoredWebsiteV1 = string;
interface IgnoredWebsiteV2 {
id: string;
website: string;
}
interface IgnoredWebsiteV3 { // [!code ++]
id: string; // [!code ++]
website: string; // [!code ++]
enabled: boolean; // [!code ++]
} // [!code ++]
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV2[]>( // [!code --]
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV3[]>( // [!code ++]
'local:ignoredWebsites',
{
defaultValue: [],
version: 2, // [!code --]
version: 3, // [!code ++]
migrations: {
// Ran when migrating from v1 to v2
2: (websites: IgnoredWebsiteV1[]): IgnoredWebsiteV2[] => {
return websites.map((website) => ({ id: nanoid(), website }));
},
// Ran when migrating from v2 to v3 // [!code ++]
3: (websites: IgnoredWebsiteV2[]): IgnoredWebsiteV3[] => { // [!code ++]
return websites.map((website) => ({ ...website, enabled: true })); // [!code ++]
}, // [!code ++]
},
},
);
```
:::
:::info
Internally, this uses a metadata property called `v` to track the value's current version.
:::
In this case, we thought that the ignored website list might change in the future, and were able to setup a versioned storage item from the start.
Realistically, you won't know a item needs versioned until you need to change it's schema. Thankfully, it's simple to add versioning to an unversioned storage item.
When a previous version isn't found, WXT assumes the version was `1`. That means you just need to set `version: 2` and add a migration for `2`, and it will just work!
Lets look at the same ignored websites example from before, but start with an unversioned item this time:
:::code-group
```ts [Unversioned]
export const ignoredWebsites = storage.defineItem<string[]>(
'local:ignoredWebsites',
{
defaultValue: [],
},
);
```
<!-- prettier-ignore -->
```ts [v2]
import { nanoid } from 'nanoid'; // [!code ++]
// Retroactively add a type for the first version // [!code ++]
type IgnoredWebsiteV1 = string; // [!code ++]
interface IgnoredWebsiteV2 { // [!code ++]
id: string; // [!code ++]
website: string; // [!code ++]
} // [!code ++]
export const ignoredWebsites = storage.defineItem<string[]>( // [!code --]
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV2[]>( // [!code ++]
'local:ignoredWebsites',
{
defaultValue: [],
version: 2, // [!code ++]
migrations: { // [!code ++]
// Ran when migrating from v1 to v2 // [!code ++]
2: (websites: IgnoredWebsiteV1[]): IgnoredWebsiteV2[] => { // [!code ++]
return websites.map((website) => ({ id: nanoid(), website })); // [!code ++]
}, // [!code ++]
}, // [!code ++]
},
);
```
:::
+14 -151
View File
@@ -1,162 +1,25 @@
# Testing
WXT provides several utils for writing tests.
## Official Frameworks
## Unit tests
WXT officially supports [Vitest](https://vitest.dev/) for unit tests and either [Playwright](https://playwright.dev/) or [Puppeteer](https://pptr.dev/) for E2E tests against Chromium browsers.
If you're using auto-imports (enabled by default), [Vitest](https://vitest.dev/) is the only testing framework that supports them.
For details setting up each testing framework, see the official examples:
If you want to use a different testing library/framework (like Jest, mocha, node:test, etc), you can keep using it, but you have two options:
<ExampleList tag="testing" />
1. Switch to Vitest (recommended)
2. Configure the testing library manually
- Disable auto-imports by setting `imports: false` in your `wxt.config.ts` file
- Manually add globals normally provided by WXT (like `__BROWSER__`) that you consume to the global scope before accessing them (`globalThis.__BROWSER__ = "chrome"`)
### Unofficial Frameworks
### Vitest Setup
Puppeteer and Playwright are the only E2E test runners that support Chrome Extensions. There are no other options at the time of writing.
Install vitest and add the `WxtVitest` plugin to your `vitest.config.ts` file.
There are other options for unit tests however, like [Jest](https://jestjs.io/), [Mocha](https://mochajs.org/), or [`node:test`](https://nodejs.org/api/test.html). **_WXT does not claim to support any of them_** because none of them support all of WXT's features, like TypeScript or auto-imports.
```sh
pnpm i -D vitest
```
If you want to try to use a different framework for unit tests, you will need to configure the environment manually:
```ts
// <root>/vitest.config.ts
import { defineConfig } from 'vitest/config';
import { WxtVitest } from 'wxt/testing';
- **Auto-imports**: Add `unimport` to your test environment or disable them by setting `imports: false` in your `wxt.config.ts` file
- **`browser` mock**: Mock the `webextension-polyfill` module globally with `wxt/dist/virtual/mock-browser.js`
- **[Remote Code Bundling](/guide/remote-code)**: If you use it, configure your environment to handle the `url:` module prefix
- **Global Variables**: If you consume them, manually define globals provided by WXT (like `__BROWSER__`) by adding them to the global scope before accessing them (`globalThis.__BROWSER__ = "chrome"`)
- **Import paths**: If you use the `@/` or `~/` path aliases, add them to your test environment
export default defineConfig({
plugins: [WxtVitest()],
test: {
server: {
deps: {
// Add any dependencies that import webextension-polyfill here, otherwise tests will attempt to import the real polyfill, breaking the
// TODO: Auto-detect these dependencies inside `WxtVitest` so maintaining this list manually isn't necessary
inline: [...],
},
},
},
});
```
And that's it. You're ready to start writing tests.
### Writing Tests
Here's a very basic test, written with a few different testing libraries, with a few different approaches for mocking the `browser` global.
:::code-group
```ts [Vitest]
import { describe, it, expect, vi } from 'vitest';
function logRuntimeId() {
// Vitest automatically mocks "browser" with "fakeBrowser"
console.log(browser.runtime.id);
}
describe('logRuntimeId', () => {
it("should log the extension's runtime ID", () => {
// Set a known ID on fakeBrowser for the test
const id = 'some-runtime-id';
fakeBrowser.runtime.id = id;
const logSpy = vi.spyOn(console, 'log');
logRuntimeId();
expect(logSpy).toBeCalledWith(id);
});
});
```
```ts [Jest - Manual Mock]
import { fakeBrowser } from 'wxt/testing';
import { browser } from 'wxt/browser';
function logRuntimeId() {
console.log(browser.runtime.id);
}
// Manually mock
jest.mock('wxt/browser', () => {
const { fakeBrowser } = require('wxt/testing');
return { browser: fakeBrowser };
});
describe('logRuntimeId', () => {
it("should log the extension's runtime ID", () => {
// Set a known ID on fakeBrowser for the test
const id = 'some-runtime-id';
fakeBrowser.runtime.id = id;
const logSpy = jest.spyOn(console, 'log');
logRuntimeId();
expect(logSpy).toBeCalledWith(id);
});
});
```
```ts [node:test - Parameterized]
import { describe, it, mock } from 'node:test';
import { assert } from 'node:assert';
import { fakeBrowser } from 'wxt/testing';
import { browser } from 'wxt/browser';
// Add browser as a parameter so fakeBrowser can be passed instead of browser
function logRuntimeId(browser = browser) {
console.log(browser.runtime.id);
}
describe('logRuntimeId', () => {
it("should log the extension's runtime ID", () => {
// Set a known ID on fakeBrowser for the test
const id = 'some-runtime-id';
fakeBrowser.runtime.id = id;
console.log = mock.fn();
// pass in fakeBrowser during tests
logRuntimeId(fakeBrowser);
assert.deepStrictEqual(console.log.mock.calls[0].arguments, [id]);
});
});
```
:::
:::warning
Without mocking the `browser` variable, you'll see errors like this:
```
This script should only be loaded in a browser extension.
```
:::
WXT provides an in-memory, partial implementation of `browser`, [`fakeBrowser`](/api/wxt/testing/variables/fakeBrowser), from the [`@webext-core/fake-browser`](https://webext-core.aklinker1.io/guide/fake-browser/) package. `fakeBrowser` works with all testing frameworks/libraries. See their docs for a list of [implemented APIs](https://webext-core.aklinker1.io/guide/fake-browser/implemented-apis.html) and more example tests.
## E2E Tests
WXT does not provide any utils for running E2E tests. There are two libraries you can use to run E2E tests for any chrome extension.
- [`playwright`](https://playwright.dev/docs/chrome-extensions) (recommended) - "A high-level API to automate web browsers"
- [`puppeteer`](https://pptr.dev/guides/chrome-extensions) - "A high-level API to control headless Chrome over the DevTools Protocol"
:::info
Note that both only support running tests on Chrome.
:::
Before running tests with either of these tools, you must build the extension with `wxt build` and then load the extension from the output directory in a new tab.
To test an extension's UI, like the popup or options page, you'll need to know the extension's ID to open the URL directly.
> _chrome-extension://`browser.runtime.id`/popup.html_
- Playwright provides an API to get your extension ID after it has been installed. [See their docs](https://playwright.dev/docs/chrome-extensions#testing).
- Puppeteer requires you know the ID before installing the extension, so you can hard code it into the URLs you open. Follow [Chrome's guide](https://developer.chrome.com/docs/extensions/mv3/manifest/key/) to setup a consistent runtime id.
:::info
You cannot test popups in their normal popup window, you have to open them in a tab.
:::
[Here's how Vitest is configured](https://github.com/wxt-dev/wxt/blob/main/src/testing/wxt-vitest-plugin.ts) for reference.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+6 -14
View File
@@ -17,25 +17,17 @@ describe('Auto Imports', () => {
export {}
declare global {
const ContentScriptContext: typeof import('wxt/client')['ContentScriptContext']
const InvalidMatchPattern: typeof import('wxt/sandbox')['InvalidMatchPattern']
const MatchPattern: typeof import('wxt/sandbox')['MatchPattern']
const browser: typeof import('wxt/browser')['browser']
const builtinDrivers: typeof import('wxt/storage')['builtinDrivers']
const createContentScriptIframe: typeof import('wxt/client')['createContentScriptIframe']
const createContentScriptUi: typeof import('wxt/client')['createContentScriptUi']
const createStorage: typeof import('wxt/storage')['createStorage']
const defineBackground: typeof import('wxt/client')['defineBackground']
const defineBackground: typeof import('wxt/sandbox')['defineBackground']
const defineConfig: typeof import('wxt')['defineConfig']
const defineContentScript: typeof import('wxt/client')['defineContentScript']
const defineDriver: typeof import('wxt/storage')['defineDriver']
const defineContentScript: typeof import('wxt/sandbox')['defineContentScript']
const defineUnlistedScript: typeof import('wxt/sandbox')['defineUnlistedScript']
const fakeBrowser: typeof import('wxt/testing')['fakeBrowser']
const joinKeys: typeof import('wxt/storage')['joinKeys']
const normalizeBaseKey: typeof import('wxt/storage')['normalizeBaseKey']
const normalizeKey: typeof import('wxt/storage')['normalizeKey']
const prefixStorage: typeof import('wxt/storage')['prefixStorage']
const restoreSnapshot: typeof import('wxt/storage')['restoreSnapshot']
const snapshot: typeof import('wxt/storage')['snapshot']
const storage: typeof import('wxt/storage')['storage']
const webExtensionDriver: typeof import('wxt/storage')['webExtensionDriver']
}
"
`);
@@ -52,7 +44,7 @@ describe('Auto Imports', () => {
".wxt/wxt.d.ts
----------------------------------------
// Generated by wxt
/// <reference types="vite/client" />
/// <reference types="wxt/vite-builder-env" />
/// <reference types="./types/imports.d.ts" />
/// <reference types="./types/paths.d.ts" />
/// <reference types="./types/i18n.d.ts" />
@@ -91,7 +83,7 @@ describe('Auto Imports', () => {
".wxt/wxt.d.ts
----------------------------------------
// Generated by wxt
/// <reference types="vite/client" />
/// <reference types="wxt/vite-builder-env" />
/// <reference types="./types/paths.d.ts" />
/// <reference types="./types/i18n.d.ts" />
/// <reference types="./types/globals.d.ts" />
-632
View File
@@ -1,552 +1,7 @@
import { describe, it, expect } from 'vitest';
import { TestProject } from '../utils';
// TODO: move to unit tests to speed this up - this doesn't have to be in E2E tests
describe('Manifest Content', () => {
describe('popup', () => {
const popupContent = (type?: 'browser_action' | 'page_action') => `
<html>
<head>
${type == null ? '' : `<meta name="manifest.type" content="${type}">`}
<meta name="manifest.default_icon" content="{ '16': '/icon/16.png' }">
<title>Default Title</title>
</head>
</html>
`;
it('should include an action for mv3', async () => {
const project = new TestProject();
project.addFile('entrypoints/popup.html', popupContent());
await project.build();
const manifest = await project.getOutputManifest();
expect(manifest.action).toEqual({
default_icon: { '16': '/icon/16.png' },
default_title: 'Default Title',
default_popup: 'popup.html',
});
});
it.each([
[undefined, 'browser_action'],
['browser_action', 'browser_action'],
['page_action', 'page_action'],
] as const)(
'should include a browser_action for mv2',
async (type, expectedType) => {
const project = new TestProject();
project.addFile('entrypoints/popup.html', popupContent(type));
await project.build({ manifestVersion: 2 });
const manifest = await project.getOutputManifest(
'.output/chrome-mv2/manifest.json',
);
expect(manifest[expectedType]).toEqual({
default_icon: { '16': '/icon/16.png' },
default_title: 'Default Title',
default_popup: 'popup.html',
});
},
);
});
describe('options', () => {
const optionsContent = `
<html>
<head>
<meta name="manifest.open_in_tab" content="false">
<meta name="manifest.chrome_style" content="true">
<meta name="manifest.browser_style" content="true">
</head>
</html>
`;
it('should include a options_ui and chrome_style for chrome', async () => {
const project = new TestProject();
project.addFile('entrypoints/options.html', optionsContent);
await project.build();
const manifest = await project.getOutputManifest();
expect(manifest.options_ui).toEqual({
open_in_tab: false,
chrome_style: true,
page: 'options.html',
});
});
it('should include a options_ui and browser_style for firefox', async () => {
const project = new TestProject();
project.addFile('entrypoints/options.html', optionsContent);
await project.build({ browser: 'firefox' });
const manifest = await project.getOutputManifest(
'.output/firefox-mv2/manifest.json',
);
expect(manifest.options_ui).toEqual({
open_in_tab: false,
browser_style: true,
page: 'options.html',
});
});
});
describe('background', () => {
const backgroundContent = `
export default defineBackground({
persistent: true,
type: "module",
main: () => {},
})
`;
it.each(['chrome', 'safari'])(
'should include scripts and persistent for %s mv2',
async (browser) => {
const project = new TestProject();
project.addFile('entrypoints/background.ts', backgroundContent);
await project.build({ browser, manifestVersion: 2 });
const manifest = await project.getOutputManifest(
`.output/${browser}-mv2/manifest.json`,
);
expect(manifest.background).toEqual({
persistent: true,
scripts: ['background.js'],
});
},
);
it.each(['chrome', 'safari'])(
'should include a service worker and type for %s mv3',
async (browser) => {
const project = new TestProject();
project.addFile('entrypoints/background.ts', backgroundContent);
await project.build({ browser, manifestVersion: 3 });
const manifest = await project.getOutputManifest(
`.output/${browser}-mv3/manifest.json`,
);
expect(manifest.background).toEqual({
type: 'module',
service_worker: 'background.js',
});
},
);
it('should include a background script and type for firefox mv3', async () => {
const project = new TestProject();
project.addFile('entrypoints/background.ts', backgroundContent);
await project.build({ browser: 'firefox', manifestVersion: 3 });
const manifest = await project.getOutputManifest(
'.output/firefox-mv3/manifest.json',
);
expect(manifest.background).toEqual({
type: 'module',
scripts: ['background.js'],
});
});
it('should include a background script and persistent for firefox mv2', async () => {
const project = new TestProject();
project.addFile('entrypoints/background.ts', backgroundContent);
await project.build({ browser: 'firefox', manifestVersion: 2 });
const manifest = await project.getOutputManifest(
'.output/firefox-mv2/manifest.json',
);
expect(manifest.background).toEqual({
persistent: true,
scripts: ['background.js'],
});
});
});
describe('icons', () => {
it('should auto-discover icons with the correct name', async () => {
const project = new TestProject();
project.addFile('public/icon-16.png');
project.addFile('public/icon/32.png');
project.addFile('public/icon@48w.png');
project.addFile('public/icon-64x64.png');
project.addFile('public/icon@96.png');
project.addFile('public/icons/128x128.png');
await project.build();
const manifest = await project.getOutputManifest();
expect(manifest.icons).toEqual({
'16': 'icon-16.png',
'32': 'icon/32.png',
'48': 'icon@48w.png',
'64': 'icon-64x64.png',
'96': 'icon@96.png',
'128': 'icons/128x128.png',
});
});
it('should return undefined when no icons are found', async () => {
const project = new TestProject();
project.addFile('public/logo.png');
project.addFile('public/icon.jpeg');
await project.build();
const manifest = await project.getOutputManifest();
expect(manifest.icons).toBeUndefined();
});
it('should allow icons to be overwritten from the wxt.config.ts file', async () => {
const project = new TestProject();
project.addFile('public/icon-16.png');
project.addFile('public/icon-32.png');
project.addFile('public/logo-16.png');
project.addFile('public/logo-32.png');
project.addFile('public/logo-48.png');
const icons = {
'16': 'logo-16.png',
'32': 'logo-32.png',
'48': 'logo-48.png',
};
project.setConfigFileConfig({
manifest: {
icons,
},
});
await project.build();
const manifest = await project.getOutputManifest();
expect(manifest.icons).toEqual(icons);
});
});
describe('content_scripts', () => {
it('should group content scripts and styles together based on their manifest properties', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/one.content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["*://google.com/*"],
main: () => {},
})`,
);
project.addFile(
'entrypoints/one.content/style.css',
`body { color: red }`,
);
project.addFile(
'entrypoints/two.content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["*://google.com/*"],
runAt: "document_end",
main: () => {},
})`,
);
project.addFile(
'entrypoints/two.content/style.css',
`body { color: green }`,
);
project.addFile(
'entrypoints/three.content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["*://google.com/*"],
runAt: "document_end",
main: () => {},
})`,
);
project.addFile(
'entrypoints/three.content/style.css',
`body { color: blue }`,
);
project.addFile(
'entrypoints/four.content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["*://duckduckgo.com/*"],
runAt: "document_end",
main: () => {},
})`,
);
project.addFile(
'entrypoints/four.content/style.css',
`body { color: yellow }`,
);
await project.build();
const manifest = await project.getOutputManifest();
expect(manifest.content_scripts).toContainEqual({
matches: ['*://duckduckgo.com/*'],
run_at: 'document_end',
css: ['content-scripts/four.css'],
js: ['content-scripts/four.js'],
});
expect(manifest.content_scripts).toContainEqual({
matches: ['*://google.com/*'],
run_at: 'document_end',
css: ['content-scripts/three.css', 'content-scripts/two.css'],
js: ['content-scripts/three.js', 'content-scripts/two.js'],
});
expect(manifest.content_scripts).toContainEqual({
matches: ['*://google.com/*'],
css: ['content-scripts/one.css'],
js: ['content-scripts/one.js'],
});
});
it('should add to any content scripts declared in wxt.config.ts', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/one.content/index.ts',
`export default defineContentScript({
matches: ["*://google.com/*"],
main: () => {},
})`,
);
project.addFile(
'entrypoints/two.content/style.css',
`body {
background-color: red;
}`,
);
project.setConfigFileConfig({
manifest: {
content_scripts: [
{
css: ['content-scripts/two.css'],
matches: ['*://*.google.com/*'],
},
],
},
});
await project.build();
const manifest = await project.getOutputManifest();
expect(manifest.content_scripts).toContainEqual({
css: ['content-scripts/two.css'],
matches: ['*://*.google.com/*'],
});
expect(manifest.content_scripts).toContainEqual({
matches: ['*://google.com/*'],
js: ['content-scripts/one.js'],
});
});
it('should add a CSS entry when cssInjectionMode is undefined', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/content/style.css',
'body { background-color: red; }',
);
project.addFile(
'entrypoints/content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["https://*.google.com/*"],
main() {},
});`,
);
await project.build();
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
.toMatchInlineSnapshot(`
".output/chrome-mv3/manifest.json
----------------------------------------
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","content_scripts":[{"matches":["https://*.google.com/*"],"css":["content-scripts/content.css"],"js":["content-scripts/content.js"]}]}"
`);
});
it('should add a CSS entry when cssInjectionMode is "manifest"', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/content/style.css',
'body { background-color: red; }',
);
project.addFile(
'entrypoints/content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["https://*.google.com/*"],
cssInjectionMode: "manifest",
main() {},
});`,
);
await project.build();
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
.toMatchInlineSnapshot(`
".output/chrome-mv3/manifest.json
----------------------------------------
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","content_scripts":[{"matches":["https://*.google.com/*"],"css":["content-scripts/content.css"],"js":["content-scripts/content.js"]}]}"
`);
});
it('should not add an entry for CSS when cssInjectionMode is "manual"', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/content/style.css',
'body { background-color: red; }',
);
project.addFile(
'entrypoints/content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["https://*.google.com/*"],
cssInjectionMode: "manual",
main() {},
});`,
);
await project.build();
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
.toMatchInlineSnapshot(`
".output/chrome-mv3/manifest.json
----------------------------------------
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","content_scripts":[{"matches":["https://*.google.com/*"],"js":["content-scripts/content.js"]}]}"
`);
});
it('should not add a content script entry for CSS when cssInjectionMode is "ui", but add a web_accessible_resources entry for MV2', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/content/style.css',
'body { background-color: red; }',
);
project.addFile(
'entrypoints/content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["https://*.google.com/*"],
cssInjectionMode: "ui",
main() {},
});`,
);
await project.build({
manifestVersion: 2,
});
expect(await project.serializeFile('.output/chrome-mv2/manifest.json'))
.toMatchInlineSnapshot(`
".output/chrome-mv2/manifest.json
----------------------------------------
{"manifest_version":2,"name":"E2E Extension","description":"Example description","version":"0.0.0","content_scripts":[{"matches":["https://*.google.com/*"],"js":["content-scripts/content.js"]}],"web_accessible_resources":["content-scripts/content.css"]}"
`);
});
it('should not add a content script entry for CSS when cssInjectionMode is "ui", but add a web_accessible_resources entry for MV3', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/content/style.css',
'body { background-color: red; }',
);
project.addFile(
'entrypoints/content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["https://*.google.com/*"],
cssInjectionMode: "ui",
main() {},
});`,
);
await project.build({
manifestVersion: 3,
});
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
.toMatchInlineSnapshot(`
".output/chrome-mv3/manifest.json
----------------------------------------
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","content_scripts":[{"matches":["https://*.google.com/*"],"js":["content-scripts/content.js"]}],"web_accessible_resources":[{"resources":["content-scripts/content.css"],"matches":["https://*.google.com/*"]}]}"
`);
});
});
it('should combine web accessible resources', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/content/style.css',
'body { background-color: red; }',
);
project.addFile(
'entrypoints/content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["https://*.google.com/*"],
cssInjectionMode: "ui",
main() {},
});`,
);
project.setConfigFileConfig({
manifest: {
web_accessible_resources: [
{ resources: ['one.png'], matches: ['https://one.com/*'] },
],
},
});
await project.build();
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
.toMatchInlineSnapshot(`
".output/chrome-mv3/manifest.json
----------------------------------------
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","web_accessible_resources":[{"resources":["one.png"],"matches":["https://one.com/*"]},{"resources":["content-scripts/content.css"],"matches":["https://*.google.com/*"]}],"content_scripts":[{"matches":["https://*.google.com/*"],"js":["content-scripts/content.js"]}]}"
`);
});
it('should respect the transformManifest option', async () => {
const project = new TestProject();
project.addFile(
'wxt.config.ts',
`import { defineConfig } from 'wxt';
export default defineConfig({
transformManifest(manifest) {
manifest.author = "Custom Author"
}
})`,
);
await project.build();
const output = await project.serializeFile(
'.output/chrome-mv3/manifest.json',
);
expect(output).toMatchInlineSnapshot(`
".output/chrome-mv3/manifest.json
----------------------------------------
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","author":"Custom Author"}"
`);
});
it.each([
{ browser: undefined, outDir: 'chrome-mv3', expected: undefined },
{ browser: 'chrome', outDir: 'chrome-mv3', expected: undefined },
@@ -575,91 +30,4 @@ describe('Manifest Content', () => {
expect(safariManifest.background.persistent).toBe(expected);
},
);
describe('versions', () => {
it.each([
['chrome', 3] as const,
['safari', 2] as const,
['edge', 3] as const,
])(
'should include version_name on %s when it needs simplified',
async (browser, manifestVersion) => {
const project = new TestProject({
version: '1.0.0-alpha1',
});
await project.build({ browser, manifestVersion });
const manifest = await project.getOutputManifest(
`.output/${browser}-mv${manifestVersion}/manifest.json`,
);
expect(manifest.version).toBe('1.0.0');
expect(manifest.version_name).toBe('1.0.0-alpha1');
},
);
it.each([['firefox', 2] as const])(
"should not include a version_name on %s because the browser doesn't support it",
async (browser, manifestVersion) => {
const project = new TestProject({
version: '1.0.0-alpha1',
});
await project.build({ browser, manifestVersion });
const manifest = await project.getOutputManifest(
`.output/${browser}-mv${manifestVersion}/manifest.json`,
);
expect(manifest.version).toBe('1.0.0');
expect(manifest.version_name).toBeUndefined();
},
);
it.each([
['chrome', 3] as const,
['firefox', 2] as const,
['safari', 3] as const,
['edge', 3] as const,
])(
'should not include the version_name if it is equal to version',
async (browser, manifestVersion) => {
const project = new TestProject({
version: '1.0.0.1',
});
await project.build({ browser, manifestVersion });
const manifest = await project.getOutputManifest(
`.output/${browser}-mv${manifestVersion}/manifest.json`,
);
expect(manifest.version).toBe('1.0.0.1');
expect(manifest.version_name).toBeUndefined();
},
);
});
it('should respect the action field in the manifest without a popup', async () => {
const project = new TestProject();
project.setConfigFileConfig({
manifest: {
action: {
default_title: 'Hello world',
},
},
});
await project.build();
expect(await project.getOutputManifest()).toMatchInlineSnapshot(`
{
"action": {
"default_title": "Hello world",
},
"description": "Example description",
"manifest_version": 3,
"name": "E2E Extension",
"version": "0.0.0",
}
`);
});
});
+7 -1
View File
@@ -7,13 +7,18 @@ describe('Output Directory Structure', () => {
project.addFile('entrypoints/.DS_Store');
project.addFile('entrypoints/.hidden1/index.html');
project.addFile('entrypoints/.hidden2.html');
project.addFile('entrypoints/unlisted.html');
await project.build();
expect(await project.serializeOutput()).toMatchInlineSnapshot(`
".output/chrome-mv3/manifest.json
----------------------------------------
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0"}"
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0"}
================================================================================
.output/chrome-mv3/unlisted.html
----------------------------------------
"
`);
});
@@ -211,6 +216,7 @@ describe('Output Directory Structure', () => {
it("should output to a custom directory when overriding 'outDir'", async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
project.setConfigFileConfig({
outDir: 'dist',
});
+7 -1
View File
@@ -4,6 +4,7 @@ import { TestProject } from '../utils';
describe('TypeScript Project', () => {
it('should generate defined constants correctly', async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
await project.build();
@@ -58,6 +59,7 @@ describe('TypeScript Project', () => {
it('should augment the types for browser.i18n.getMessage', async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
project.addFile(
'public/_locales/en/messages.json',
JSON.stringify({
@@ -216,6 +218,7 @@ describe('TypeScript Project', () => {
it('should reference all the required types in a single declaration file', async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
await project.build();
@@ -224,7 +227,7 @@ describe('TypeScript Project', () => {
".wxt/wxt.d.ts
----------------------------------------
// Generated by wxt
/// <reference types="vite/client" />
/// <reference types="wxt/vite-builder-env" />
/// <reference types="./types/imports.d.ts" />
/// <reference types="./types/paths.d.ts" />
/// <reference types="./types/i18n.d.ts" />
@@ -235,6 +238,7 @@ describe('TypeScript Project', () => {
it('should generate a TSConfig file for the project', async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
await project.build();
@@ -275,6 +279,7 @@ describe('TypeScript Project', () => {
it('should generate correct path aliases for a custom srcDir', async () => {
const project = new TestProject();
project.addFile('src/entrypoints/unlisted.html');
project.setConfigFileConfig({
srcDir: 'src',
});
@@ -318,6 +323,7 @@ describe('TypeScript Project', () => {
it('should add additional path aliases listed in the alias config, preventing defaults from being overridden', async () => {
const project = new TestProject();
project.addFile('src/entrypoints/unlisted.html');
project.setConfigFileConfig({
srcDir: 'src',
alias: {
+3 -2
View File
@@ -61,6 +61,7 @@ describe('User Config', () => {
it('should merge inline and user config based manifests', async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
project.addFile(
'wxt.config.ts',
`import { defineConfig } from 'wxt';
@@ -79,8 +80,8 @@ describe('User Config', () => {
}),
});
const output = await project.serializeOutput();
expect(output).toMatchInlineSnapshot(`
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
.toMatchInlineSnapshot(`
".output/chrome-mv3/manifest.json
----------------------------------------
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","example_customization":["3","build","production","chrome"]}"
+49 -25
View File
@@ -1,7 +1,7 @@
{
"name": "wxt",
"type": "module",
"version": "0.11.0",
"version": "0.13.5",
"description": "Next gen framework for developing web extensions",
"engines": {
"node": ">=18",
@@ -30,47 +30,70 @@
"bin",
"dist"
],
"bin": "./bin/wxt.cjs",
"bin": "./bin/wxt.mjs",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"require": "./dist/index.cjs",
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./client": {
"import": "./dist/client.js",
"types": "./dist/client.d.ts"
"types": "./dist/client.d.ts",
"import": "./dist/client.js"
},
"./sandbox": {
"import": "./dist/sandbox.js",
"types": "./dist/sandbox.d.ts"
"types": "./dist/sandbox.d.ts",
"import": "./dist/sandbox.js"
},
"./browser": {
"import": "./dist/browser.js",
"types": "./dist/browser.d.ts"
"types": "./dist/browser.d.ts",
"import": "./dist/browser.js"
},
"./testing": {
"require": "./dist/testing.cjs",
"import": "./dist/testing.js",
"types": "./dist/testing.d.ts"
"import": {
"types": "./dist/testing.d.ts",
"default": "./dist/testing.js"
},
"require": {
"types": "./dist/testing.d.cts",
"default": "./dist/testing.cjs"
}
},
"./storage": {
"require": "./dist/storage.cjs",
"import": "./dist/storage.js",
"types": "./dist/storage.d.ts"
"import": {
"types": "./dist/storage.d.ts",
"default": "./dist/storage.js"
},
"require": {
"types": "./dist/storage.d.cts",
"default": "./dist/storage.cjs"
}
},
"./vite-builder-env": {
"types": "./dist/vite-builder-env.d.ts"
}
},
"scripts": {
"wxt": "tsx src/cli.ts",
"build": "tsx scripts/build.ts",
"format": "prettier --write .",
"format:check": "prettier --write .",
"compile": "tsc --noEmit",
"format:check": "prettier --check .",
"lint": "run-p -c -s lint:*",
"lint:eslint": "echo 'ESLint: TODO'",
"lint:package": "publint",
"compile": "run-s -c compile:*",
"compile:wxt": "tsc --noEmit",
"compile:virtual": "tsc --noEmit -p src/virtual",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test:coverage": "vitest run --coverage.enabled \"--coverage.include=src/**\" \"--coverage.exclude=src/core/utils/testing/**\" \"--coverage.exclude=**/*.d.ts\"",
"prepare": "simple-git-hooks",
"prepublish": "pnpm -s build",
"docs:gen": "typedoc --options docs/typedoc.json",
@@ -82,14 +105,15 @@
"dependencies": {
"@types/webextension-polyfill": "^0.10.5",
"@webext-core/fake-browser": "^1.3.1",
"@webext-core/isolated-element": "^1.0.4",
"@webext-core/match-patterns": "^1.0.2",
"@webext-core/isolated-element": "^1.1.1",
"@webext-core/match-patterns": "^1.0.3",
"async-mutex": "^0.4.0",
"c12": "^1.5.1",
"cac": "^6.7.14",
"chokidar": "^3.5.3",
"consola": "^3.2.3",
"defu": "^6.1.3",
"dequal": "^2.0.3",
"esbuild": "^0.19.5",
"fast-glob": "^3.3.1",
"filesize": "^10.0.8",
@@ -108,7 +132,6 @@
"prompts": "^2.4.2",
"rollup-plugin-visualizer": "^5.9.2",
"unimport": "^3.4.0",
"unstorage": "^1.9.0",
"vite": "^5.0.0",
"web-ext-run": "^0.1.0",
"webextension-polyfill": "^0.10.0",
@@ -129,6 +152,7 @@
"npm-run-all": "^4.1.5",
"p-map": "^7.0.0",
"prettier": "^3.1.0",
"publint": "^0.2.6",
"simple-git-hooks": "^2.9.0",
"tsup": "^8.0.1",
"tsx": "^4.6.2",
@@ -136,8 +160,8 @@
"typedoc-plugin-markdown": "4.0.0-next.23",
"typedoc-vitepress-theme": "1.0.0-next.3",
"typescript": "^5.3.2",
"vitepress": "1.0.0-rc.31",
"vitest": "^1.0.0",
"vitepress": "1.0.0-rc.34",
"vitest": "^1.1.0",
"vitest-mock-extended": "^1.3.1",
"vue": "^3.3.10"
},
+367 -1052
View File
File diff suppressed because it is too large Load Diff
+16 -10
View File
@@ -16,22 +16,22 @@ const startTime = Date.now();
const outDir = 'dist';
await fs.rm(path.join(outDir, '*'), { recursive: true, force: true });
const preset: tsup.Options = {
const preset = {
dts: true,
silent: true,
sourcemap: false,
external: [
'vite',
'virtual:user-unlisted-script',
'virtual:user-content-script',
'virtual:user-content-script-isolated-world',
'virtual:user-content-script-main-world',
'virtual:user-background',
],
};
} satisfies tsup.Options;
function spinnerPMap(configs: tsup.Options[]) {
let completed = 0;
let progress = 1;
const updateSpinner = () => {
spinner.text = `${spinnerText} [${completed}/${configs.length}]`;
spinner.text = `${spinnerText} [${progress}/${configs.length}]`;
};
updateSpinner();
@@ -39,7 +39,7 @@ function spinnerPMap(configs: tsup.Options[]) {
config,
async (config) => {
const res = await tsup.build(config);
completed++;
progress++;
updateSpinner();
return res;
},
@@ -76,8 +76,10 @@ const config: tsup.Options[] = [
...preset,
entry: {
'virtual/background-entrypoint': 'src/virtual/background-entrypoint.ts',
'virtual/content-script-entrypoint':
'src/virtual/content-script-entrypoint.ts',
'virtual/content-script-isolated-world-entrypoint':
'src/virtual/content-script-isolated-world-entrypoint.ts',
'virtual/content-script-main-world-entrypoint':
'src/virtual/content-script-main-world-entrypoint.ts',
'virtual/mock-browser': 'src/virtual/mock-browser.ts',
'virtual/reload-html': 'src/virtual/reload-html.ts',
'virtual/unlisted-script-entrypoint':
@@ -86,6 +88,7 @@ const config: tsup.Options[] = [
format: ['esm'],
splitting: false,
dts: false,
external: [...preset.external, 'wxt'],
},
// CJS-only
{
@@ -93,7 +96,7 @@ const config: tsup.Options[] = [
entry: {
cli: 'src/cli.ts',
},
format: ['cjs'],
format: ['esm'],
},
];
@@ -103,6 +106,9 @@ await spinnerPMap(config).catch((err) => {
process.exit(1);
});
// Copy "public" files that need shipped inside WXT
await fs.copyFile('src/vite-builder-env.d.ts', 'dist/vite-builder-env.d.ts');
spinner.clear().stop();
const duration = Date.now() - startTime;
+739 -9
View File
@@ -1,11 +1,12 @@
import { fakeBrowser } from '@webext-core/fake-browser';
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { browser } from '~/browser';
import { storage } from '~/storage';
describe('Storage Utils', () => {
beforeEach(() => {
fakeBrowser.reset();
storage.unwatch();
});
describe.each(['local', 'sync', 'managed', 'session'] as const)(
@@ -15,33 +16,89 @@ describe('Storage Utils', () => {
it('should return the value from the correct storage area', async () => {
const expected = 123;
await fakeBrowser.storage[storageArea].set({ count: expected });
expect(await storage.getItem(`${storageArea}:count`)).toBe(expected);
const actual = await storage.getItem(`${storageArea}:count`);
expect(actual).toBe(expected);
});
it('should return the value if multiple : are use in the key', async () => {
const expected = 'value';
await fakeBrowser.storage[storageArea].set({ 'some:key': expected });
const actual = await storage.getItem(`${storageArea}:some:key`);
expect(actual).toBe(expected);
});
it("should return null if the value doesn't exist", async () => {
expect(await storage.getItem(`${storageArea}:count`)).toBeNull();
const actual = await storage.getItem(`${storageArea}:count`);
expect(actual).toBeNull();
});
it('should return the default value if passed in options', async () => {
const expected = 0;
const actual = await storage.getItem(`${storageArea}:count`, {
defaultValue: expected,
});
expect(actual).toBe(expected);
});
});
describe('getItems', () => {
it('should return an array of values in the same order as the keys passed in', async () => {
it('should return an array of values', async () => {
const expected = [
{ key: `${storageArea}:count`, value: 234 },
{ key: `${storageArea}:installDate`, value: null },
{ key: `${storageArea}:otherValue`, value: 345 },
];
const params = [
expected[0].key,
expected[1].key,
{
key: expected[2].key,
options: { defaultValue: expected[2].value },
},
];
const keys = expected.map((item) => item.key);
await fakeBrowser.storage[storageArea].set({
count: expected[0].value,
});
const actual = await storage.getItems(keys);
const actual = await storage.getItems(params);
expect(actual).toHaveLength(3);
expected.forEach((item) => {
expect(actual).toContainEqual(item);
});
});
});
describe('getMeta', () => {
it('should return item metadata from key+$', async () => {
const expected = { v: 1 };
await fakeBrowser.storage[storageArea].set({ count$: expected });
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual(expected);
});
it('should return an empty object if missing', async () => {
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual({});
});
});
describe('setItem', () => {
it('should set the value in the correct storage area', () => {});
it('should set the value in the correct storage area', async () => {
const key = `${storageArea}:count`;
const value = 321;
await storage.setItem(key, value);
});
it.each([undefined, null])(
'should remove the item from storage when setting the value to %s',
@@ -57,12 +114,685 @@ describe('Storage Utils', () => {
);
});
describe('setItems', () => {
it('should set multiple items in storage', async () => {
const expected = [
{ key: `${storageArea}:count`, value: 234 },
{ key: `${storageArea}:installDate`, value: null },
];
await fakeBrowser.storage[storageArea].set({
count: 123,
installDate: 321,
});
await storage.setItems(expected);
const actual = await storage.getItems(
expected.map((item) => item.key),
);
expect(actual).toHaveLength(2);
expected.forEach((item) => {
expect(actual).toContainEqual(item);
});
});
});
describe('setMeta', () => {
it('should set metadata at key+$', async () => {
const existing = { v: 1 };
await browser.storage[storageArea].set({ count$: existing });
const newValues = {
date: Date.now(),
};
const expected = { ...existing, ...newValues };
await storage.setMeta(`${storageArea}:count`, newValues);
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual(expected);
});
it.each([undefined, null])(
'should remove any properties set to %s',
async (version) => {
const existing = { v: 1 };
await browser.storage[storageArea].set({ count$: existing });
const expected = {};
await storage.setMeta(`${storageArea}:count`, { v: version });
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual(expected);
},
);
});
describe('removeItem', () => {
it('should remove the key from storage', async () => {
await fakeBrowser.storage[storageArea].set({ count: 456 });
await storage.removeItem(`${storageArea}:count`);
expect(await browser.storage[storageArea].get()).toEqual({});
await storage.removeItem(`${storageArea}:count`);
const actual = await storage.getItem(`${storageArea}:count`);
expect(actual).toBeNull();
});
it('should not remove the metadata by default', async () => {
const expected = { v: 1 };
await fakeBrowser.storage[storageArea].set({
count$: expected,
count: 3,
});
await storage.removeItem(`${storageArea}:count`);
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual(expected);
});
it('should remove the metadata when requested', async () => {
await fakeBrowser.storage[storageArea].set({
count$: { v: 1 },
count: 3,
});
await storage.removeItem(`${storageArea}:count`, {
removeMeta: true,
});
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual({});
});
});
describe('removeItems', () => {
it('should remove multiple items', async () => {
const key1 = `${storageArea}:one`;
const key2 = `${storageArea}:two`;
const key3 = `${storageArea}:three`;
await fakeBrowser.storage[storageArea].set({
['one']: '1',
['two']: null,
['two$']: { v: 1 },
['three']: '1',
['three$']: { v: 1 },
});
await storage.removeItems([
key1,
key2,
{ key: key3, options: { removeMeta: true } },
]);
expect(await storage.getItem(key1)).toBeNull();
expect(await storage.getItem(key2)).toBeNull();
expect(await storage.getMeta(key2)).toEqual({ v: 1 });
expect(await storage.getItem(key3)).toBeNull();
expect(await storage.getMeta(key3)).toEqual({});
});
});
describe('removeMeta', () => {
it('should remove all metadata', async () => {
await fakeBrowser.storage[storageArea].set({ count$: { v: 4 } });
await storage.removeMeta(`${storageArea}:count`);
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual({});
});
it('should only remove specific properties', async () => {
await fakeBrowser.storage[storageArea].set({
count$: { v: 4, d: Date.now() },
});
await storage.removeMeta(`${storageArea}:count`, ['d']);
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual({ v: 4 });
});
});
describe('snapshot', () => {
it('should return a snapshot of the entire storage without area prefixes', async () => {
const expected = {
count: 1,
count$: { v: 2 },
example: 'test',
};
await fakeBrowser.storage[storageArea].set(expected);
const actual = await storage.snapshot(storageArea);
expect(actual).toEqual(expected);
});
it('should exclude specific properties and their metadata', async () => {
const input = {
count: 1,
count$: { v: 2 },
example: 'test',
};
const excludeKeys = ['count'];
const expected = {
example: 'test',
};
await fakeBrowser.storage[storageArea].set(input);
const actual = await storage.snapshot(storageArea, { excludeKeys });
expect(actual).toEqual(expected);
});
});
describe('restoreSnapshot', () => {
it('should restore a snapshot object by setting all values in storage', async () => {
const data = {
one: 'one',
two: 'two',
};
const existing = {
two: 'two-two',
three: 'three',
};
await fakeBrowser.storage[storageArea].set(existing);
await storage.restoreSnapshot(storageArea, data);
const actual = await storage.snapshot(storageArea);
expect(actual).toEqual({ ...existing, ...data });
});
it('should overwrite, not merge, any metadata keys in the snapshot', async () => {
const existing = {
count: 1,
count$: {
v: 2,
},
};
const data = {
count$: {
restoredAt: Date.now(),
},
};
const expected = {
...existing,
count$: data.count$,
};
await fakeBrowser.storage[storageArea].set(existing);
await storage.restoreSnapshot(storageArea, data);
const actual = await storage.snapshot(storageArea);
expect(actual).toEqual(expected);
});
});
describe('watch', () => {
it('should not trigger if the changed key is different from the requested key', async () => {
const cb = vi.fn();
storage.watch(`${storageArea}:key`, cb);
await storage.setItem(`${storageArea}:not-the-key`, '123');
expect(cb).not.toBeCalled();
});
it("should not trigger if the value doesn't change", async () => {
const cb = vi.fn();
const value = '123';
await storage.setItem(`${storageArea}:key`, value);
storage.watch(`${storageArea}:key`, cb);
await storage.setItem(`${storageArea}:key`, value);
expect(cb).not.toBeCalled();
});
it('should call the callback when the value changes', async () => {
const cb = vi.fn();
const newValue = '123';
const oldValue = null;
storage.watch(`${storageArea}:key`, cb);
await storage.setItem(`${storageArea}:key`, newValue);
expect(cb).toBeCalledTimes(1);
expect(cb).toBeCalledWith(newValue, oldValue);
});
it('should remove the listener when calling the returned function', async () => {
const cb = vi.fn();
const unwatch = storage.watch(`${storageArea}:key`, cb);
unwatch();
await storage.setItem(`${storageArea}:key`, '123');
expect(cb).not.toBeCalled();
});
});
describe('unwatch', () => {
it('should remove all watch listeners', async () => {
const cb = vi.fn();
storage.watch(`${storageArea}:key`, cb);
storage.unwatch();
await storage.setItem(`${storageArea}:key`, '123');
expect(cb).not.toBeCalled();
});
});
describe('defineItem', () => {
describe('versioning', () => {
/**
* This works because fakeBrowser is syncrounous, and is will finish any number of chained
* calls within a single tick of the event loop, ie: a timeout of 0.
*/
async function waitForMigrations() {
return new Promise((res) => setTimeout(res));
}
it('should migrate values to the latest when a version upgrade is detected', async () => {
await fakeBrowser.storage[storageArea].set({
count: 2,
count$: { v: 1 },
});
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
const migrateToV3 = vi.fn((oldCount) => oldCount * 3);
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
{
version: 3,
migrations: {
2: migrateToV2,
3: migrateToV3,
},
},
);
await waitForMigrations();
const actualValue = await item.getValue();
const actualMeta = await item.getMeta();
expect(actualValue).toEqual(12);
expect(actualMeta).toEqual({ v: 3 });
expect(migrateToV2).toBeCalledTimes(1);
expect(migrateToV2).toBeCalledWith(2);
expect(migrateToV3).toBeCalledTimes(1);
expect(migrateToV3).toBeCalledWith(4);
});
it("should not run migrations if the value doesn't exist yet", async () => {
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
const migrateToV3 = vi.fn((oldCount) => oldCount * 3);
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
{
version: 3,
migrations: {
2: migrateToV2,
3: migrateToV3,
},
},
);
await waitForMigrations();
const actualValue = await item.getValue();
const actualMeta = await item.getMeta();
expect(actualValue).toBeNull();
expect(actualMeta).toEqual({});
expect(migrateToV2).not.toBeCalled();
expect(migrateToV3).not.toBeCalled();
});
it('should run the v2 migration when converting an unversioned item to a versioned one', async () => {
await fakeBrowser.storage[storageArea].set({
count: 2,
});
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
{
version: 2,
migrations: {
2: migrateToV2,
},
},
);
await waitForMigrations();
const actualValue = await item.getValue();
const actualMeta = await item.getMeta();
expect(actualValue).toEqual(4);
expect(actualMeta).toEqual({ v: 2 });
expect(migrateToV2).toBeCalledTimes(1);
expect(migrateToV2).toBeCalledWith(2);
});
it('Should not run old migrations if the version is unchanged', async () => {
await fakeBrowser.storage[storageArea].set({
count: 2,
count$: { v: 3 },
});
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
const migrateToV3 = vi.fn((oldCount) => oldCount * 3);
storage.defineItem<number, { v: number }>(`${storageArea}:count`, {
version: 3,
migrations: {
2: migrateToV2,
3: migrateToV3,
},
});
await waitForMigrations();
expect(migrateToV2).not.toBeCalled();
expect(migrateToV3).not.toBeCalled();
});
it('should skip missing migration functions', async () => {
await fakeBrowser.storage[storageArea].set({
count: 2,
count$: { v: 0 },
});
const migrateToV1 = vi.fn((oldCount) => oldCount * 1);
const migrateToV3 = vi.fn((oldCount) => oldCount * 3);
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
{
version: 3,
migrations: {
1: migrateToV1,
3: migrateToV3,
},
},
);
await waitForMigrations();
const actualValue = await item.getValue();
const actualMeta = await item.getMeta();
expect(actualValue).toEqual(6);
expect(actualMeta).toEqual({ v: 3 });
expect(migrateToV1).toBeCalledTimes(1);
expect(migrateToV1).toBeCalledWith(2);
expect(migrateToV3).toBeCalledTimes(1);
expect(migrateToV3).toBeCalledWith(2);
});
it('should throw an error if the new version is less than the previous version', async () => {
const prevVersion = 2;
const nextVersion = 1;
await fakeBrowser.storage[storageArea].set({
count: 0,
count$: { v: prevVersion },
});
const item = storage.defineItem(`${storageArea}:count`, {
version: nextVersion,
});
// @ts-expect-error: _migrationsCompleted is returned, but untyped
await expect(item._migrationsCompleted).rejects.toThrow(
'version downgrade detected',
);
});
});
describe('getValue', () => {
it('should return the value from storage', async () => {
const expected = 2;
const item = storage.defineItem<number>(`${storageArea}:count`);
await fakeBrowser.storage[storageArea].set({ count: expected });
const actual = await item.getValue();
expect(actual).toBe(expected);
});
it('should return null if missing', async () => {
const item = storage.defineItem<number>(`${storageArea}:count`);
const actual = await item.getValue();
expect(actual).toBeNull();
});
it('should return the provided default value if missing', async () => {
const expected = 0;
const item = storage.defineItem(`${storageArea}:count`, {
defaultValue: expected,
});
const actual = await item.getValue();
expect(actual).toEqual(expected);
});
});
describe('getMeta', () => {
it('should return the value from storage at key+$', async () => {
const expected = { v: 2 };
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
);
await fakeBrowser.storage[storageArea].set({ count$: expected });
const actual = await item.getMeta();
expect(actual).toBe(expected);
});
it('should return an empty object if missing', async () => {
const expected = {};
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
);
const actual = await item.getMeta();
expect(actual).toEqual(expected);
});
});
describe('setValue', () => {
it('should set the value in storage', async () => {
const expected = 1;
const item = storage.defineItem<number>(`${storageArea}:count`);
await item.setValue(expected);
const actual = await item.getValue();
expect(actual).toBe(expected);
});
it.each([undefined, null])(
'should remove the value in storage when %s is passed in',
async (value) => {
const item = storage.defineItem<number>(`${storageArea}:count`);
// @ts-expect-error: undefined is not assignable to null, but we're testing that case on purpose
await item.setValue(value);
const actual = await item.getValue();
expect(actual).toBeNull();
},
);
});
describe('setMeta', () => {
it('should set metadata at key+$', async () => {
const expected = { date: Date.now() };
const item = storage.defineItem<number, { date: number }>(
`${storageArea}:count`,
);
await item.setMeta(expected);
const actual = await item.getMeta();
expect(actual).toEqual(expected);
});
it('should add to metadata if already present', async () => {
const existing = { v: 2 };
const newFields = { date: Date.now() };
const expected = { ...existing, ...newFields };
const item = storage.defineItem<
number,
{ date: number; v: number }
>(`${storageArea}:count`);
await fakeBrowser.storage[storageArea].set({
count$: existing,
});
await item.setMeta(newFields);
const actual = await item.getMeta();
expect(actual).toEqual(expected);
});
});
describe('removeValue', () => {
it('should remove the key from storage', async () => {
const item = storage.defineItem(`${storageArea}:count`);
await fakeBrowser.storage[storageArea].set({ count: 456 });
await item.removeValue();
const actual = await item.getValue();
expect(actual).toBeNull();
});
it('should not remove the metadata by default', async () => {
const item = storage.defineItem(`${storageArea}:count`);
const expected = { v: 1 };
await fakeBrowser.storage[storageArea].set({
count$: expected,
count: 3,
});
await item.removeValue();
const actual = await item.getMeta();
expect(actual).toEqual(expected);
});
it('should remove the metadata when requested', async () => {
const item = storage.defineItem(`${storageArea}:count`);
await fakeBrowser.storage[storageArea].set({
count$: { v: 1 },
count: 3,
});
await item.removeValue({ removeMeta: true });
const actual = await item.getMeta();
expect(actual).toEqual({});
});
});
describe('removeMeta', () => {
it('should remove all metadata', async () => {
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
);
await fakeBrowser.storage[storageArea].set({ count$: { v: 4 } });
await item.removeMeta();
const actual = await item.getMeta();
expect(actual).toEqual({});
});
it('should only remove specific properties', async () => {
const item = storage.defineItem<number, { v: number; d: number }>(
`${storageArea}:count`,
);
await fakeBrowser.storage[storageArea].set({
count$: { v: 4, d: Date.now() },
});
await item.removeMeta(['d']);
const actual = await item.getMeta();
expect(actual).toEqual({ v: 4 });
});
});
describe('watch', () => {
it("should not trigger if the changed key is different from the item's key", async () => {
const item = storage.defineItem(`${storageArea}:key`);
const cb = vi.fn();
item.watch(cb);
await storage.setItem(`${storageArea}:not-the-key`, '123');
expect(cb).not.toBeCalled();
});
it("should not trigger if the value doesn't change", async () => {
const item = storage.defineItem(`${storageArea}:key`);
const cb = vi.fn();
const value = '123';
await item.setValue(value);
item.watch(cb);
await item.setValue(value);
expect(cb).not.toBeCalled();
});
it('should call the callback when the value changes', async () => {
const item = storage.defineItem(`${storageArea}:key`);
const cb = vi.fn();
const newValue = '123';
const oldValue = null;
item.watch(cb);
await item.setValue(newValue);
expect(cb).toBeCalledTimes(1);
expect(cb).toBeCalledWith(newValue, oldValue);
});
it('should remove the listener when calling the returned function', async () => {
const item = storage.defineItem(`${storageArea}:key`);
const cb = vi.fn();
const unwatch = item.watch(cb);
unwatch();
await item.setValue('123');
expect(cb).not.toBeCalled();
});
});
describe('unwatch', () => {
it('should remove all watch listeners', async () => {
const item = storage.defineItem(`${storageArea}:key`);
const cb = vi.fn();
item.watch(cb);
storage.unwatch();
await item.setValue('123');
expect(cb).not.toBeCalled();
});
});
});
},
+62
View File
@@ -17,3 +17,65 @@ export interface WxtI18n extends I18n.Static {
}
export const browser: AugmentedBrowser = originalBrowser;
// re-export all the types from webextension-polyfill
// Because webextension-polyfill uses a weird namespace with "import export", there isn't a good way
// to get these types without re-listing them.
/** @ignore */
export type {
ActivityLog,
Alarms,
Bookmarks,
Action,
BrowserAction,
BrowserSettings,
BrowsingData,
CaptivePortal,
Clipboard,
Commands,
ContentScripts,
ContextualIdentities,
Cookies,
DeclarativeNetRequest,
Devtools,
Dns,
Downloads,
Events,
Experiments,
Extension,
ExtensionTypes,
Find,
GeckoProfiler,
History,
I18n,
Identity,
Idle,
Management,
Manifest,
ContextMenus,
Menus,
NetworkStatus,
NormandyAddonStudy,
Notifications,
Omnibox,
PageAction,
Permissions,
Pkcs11,
Privacy,
Proxy,
Runtime,
Scripting,
Search,
Sessions,
SidebarAction,
Storage,
Tabs,
Theme,
TopSites,
Types,
Urlbar,
UserScripts,
WebNavigation,
WebRequest,
Windows,
} from 'webextension-polyfill';
+3
View File
@@ -5,6 +5,9 @@ import consola, { LogLevels } from 'consola';
import { printHeader } from '~/core/utils/log';
import { formatDuration } from '~/core/utils/time';
// TODO: Remove. See https://github.com/wxt-dev/wxt/issues/277
process.env.VITE_CJS_IGNORE_WARNING = 'true';
const cli = cac('wxt');
cli.help();
cli.version(version);
@@ -5,6 +5,7 @@ import { ContentScriptContext } from '~/client/content-scripts/content-script-co
import { createIsolatedElement } from '@webext-core/isolated-element';
import { mock } from 'vitest-mock-extended';
import { browser } from '~/browser';
import { faker } from '@faker-js/faker';
vi.mock('@webext-core/isolated-element', async () => {
const { vi } = await import('vitest');
@@ -332,4 +333,26 @@ describe('createContentScriptUi', () => {
expect(document.querySelector('test-app')).toBeDefined();
});
});
it('should forward isolateEvents into createIsolatedElement', async () => {
const isolateEvents = faker.helpers.arrayElement([
undefined,
true,
false,
['click', 'keydown'],
]);
await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
mount: testApp,
isolateEvents,
});
expect(createIsolatedElementMock).toBeCalledTimes(1);
expect(createIsolatedElementMock).toBeCalledWith(
expect.objectContaining({
isolateEvents,
}),
);
});
});
@@ -1,6 +1,8 @@
import { ContentScriptDefinition } from '~/types';
import { browser } from '~/browser';
import { logger } from '~/client/utils/logger';
import { logger } from '~/sandbox/utils/logger';
import { WxtLocationChangeEvent, getUniqueEventName } from './custom-events';
import { createLocationWatcher } from './location-watcher';
/**
* Implements [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController).
@@ -14,6 +16,7 @@ export class ContentScriptContext implements AbortController {
#isTopFrame = window.self === window.top;
#abortController: AbortController;
#locationWatcher = createLocationWatcher(this);
constructor(
private readonly contentScriptName: string,
@@ -137,23 +140,42 @@ export class ContentScriptContext implements AbortController {
/**
* Call `target.addEventListener` and remove the event listener when the context is invalidated.
*
* Includes additional events useful for content scripts:
*
* - `"wxt:locationchange"` - Triggered when HTML5 history mode is used to change URL. Content
* scripts are not reloaded when navigating this way, so this can be used to reset the content
* script state on URL change, or run custom code.
*
* @example
* ctx.addEventListener(window, "mousemove", () => {
* // ...
* });
* ctx.addEventListener(document, "visibilitychange", () => {
* // ...
* });
* ctx.addEventListener(document, "wxt:locationchange", () => {
* // ...
* });
*/
addEventListener(
target: any,
type: string,
handler: (event: Event) => void,
addEventListener<
TTarget extends EventTarget,
TType extends keyof WxtContentScriptEventMap,
>(
target: TTarget,
type: TType,
handler: (event: WxtContentScriptEventMap[TType]) => void,
options?: AddEventListenerOptions,
) {
target.addEventListener?.(type, handler, options);
this.onInvalidated(
() => target.removeEventListener?.(type, handler, options),
if (type === 'wxt:locationchange') {
// Start the location watcher when adding the event for the first time
if (this.isValid) this.#locationWatcher.run();
}
target.addEventListener?.(
type.startsWith('wxt:') ? getUniqueEventName(type) : type,
// @ts-expect-error: Event don't match, but that's OK, EventTarget doesn't allow custom types in the callback
handler,
{
...options,
signal: this.signal,
},
);
}
@@ -193,3 +215,7 @@ export class ContentScriptContext implements AbortController {
this.onInvalidated(() => removeEventListener('message', cb));
}
}
interface WxtContentScriptEventMap extends WindowEventMap {
'wxt:locationchange': WxtLocationChangeEvent;
}
@@ -1,4 +1,4 @@
import browser from 'webextension-polyfill';
import { browser } from '~/browser';
import {
ContentScriptAnchoredOptions,
ContentScriptPositioningOptions,
@@ -1,6 +1,6 @@
import { createIsolatedElement } from '@webext-core/isolated-element';
import { browser } from '~/browser';
import { logger } from '~/client/utils/logger';
import { logger } from '~/sandbox/utils/logger';
import { ContentScriptContext } from './content-script-context';
import {
ContentScriptAnchoredOptions,
@@ -10,10 +10,10 @@ import {
} from '../utils/content-script-ui';
/**
* Utility for mounting content script UI's with isolated styles. Automatically removed from the DOM
* when the content script's context is invalidated.
* Utility for mounting content script UI's with isolated styles and controlled event bubbling.
* Automatically removed from the DOM when the content script's context is invalidated.
*
* See https://wxt.dev/entrypoints/content-scripts.html#ui for full documentation.
* See https://wxt.dev/guide/content-script-ui.html for full documentation.
*
* @example
* // entrypoints/example-ui.content/index.ts
@@ -31,7 +31,7 @@ import {
* const app = document.createElement("div");
* app.textContent = "Content Script UI";
* container.append(app);
* }
* },
* })
* ui.mount();
* }
@@ -56,6 +56,7 @@ export async function createContentScriptUi<TApp>(
textContent: css.join('\n').trim(),
},
mode: 'open',
isolateEvents: options.isolateEvents,
});
let mounted: TApp;
@@ -167,7 +168,16 @@ export type ContentScriptUiOptions<TApp> = ContentScriptPositioningOptions &
* set `cssInjectionMode: "ui"`, the imported CSS will be included automatically. You do not need
* to pass those styles in here. This is for any additional styles not in the imported CSS.
*
* See https://wxt.dev/entrypoints/content-scripts.html#ui for more info.
* See https://wxt.dev/guide/content-script-ui.html for more info.
*/
css?: string;
/**
* When enabled, `event.stopPropagation` will be called on events trying to bubble out of the
* shadow root.
*
* - Set to `true` to stop the propagation of a default set of events,
* `["keyup", "keydown", "keypress"]`
* - Set to an array of event names to stop the propagation of a custom list of events
*/
isolateEvents?: boolean | string[];
};
@@ -0,0 +1,24 @@
import { browser } from '~/browser';
export class WxtLocationChangeEvent extends Event {
static EVENT_NAME = getUniqueEventName('wxt:locationchange');
constructor(
readonly newUrl: URL,
readonly oldUrl: URL,
) {
super(WxtLocationChangeEvent.EVENT_NAME, {});
}
}
/**
* Returns an event name unique to the extension and content script that's running.
*/
export function getUniqueEventName(eventName: string): string {
// During the build process, __ENTRYPOINT__ is not defined when importing entrypoints to get their
// metadata.
const entrypointName =
typeof __ENTRYPOINT__ === 'undefined' ? 'build' : __ENTRYPOINT__;
return `${browser.runtime.id}:${entrypointName}:${eventName}`;
}
-1
View File
@@ -1,4 +1,3 @@
export * from './content-script-context';
export * from './content-script-ui';
export * from './content-script-iframe';
export * from './define-content-script';
@@ -0,0 +1,30 @@
import { ContentScriptContext } from '.';
import { WxtLocationChangeEvent } from './custom-events';
/**
* Create a util that watches for URL changes, dispatching the custom event when detected. Stops
* watching when content script is invalidated.
*/
export function createLocationWatcher(ctx: ContentScriptContext) {
let interval: number | undefined;
let oldUrl: URL;
return {
/**
* Ensure the location watcher is actively looking for URL changes. If it's already watching,
* this is a noop.
*/
run() {
if (interval != null) return;
oldUrl = new URL(location.href);
interval = ctx.setInterval(() => {
let newUrl = new URL(location.href);
if (newUrl.href !== oldUrl.href) {
window.dispatchEvent(new WxtLocationChangeEvent(newUrl, oldUrl));
oldUrl = newUrl;
}
}, 1e3);
},
};
}
+11 -1
View File
@@ -1,5 +1,15 @@
/**
* Any runtime APIs that use the web extension APIs.
*
* @module wxt/client
*/
export * from './define-background';
export * from './content-scripts';
export {
ContentScriptOverlayAlignment,
ContentScriptAppendMode,
ContentScriptPositioningOptions,
ContentScriptAnchoredOptions,
ContentScriptInlinePositioningOptions,
ContentScriptOverlayPositioningOptions,
ContentScriptModalPositioningOptions,
} from './utils/content-script-ui';
+42 -24
View File
@@ -4,6 +4,9 @@ export type ContentScriptOverlayAlignment =
| 'bottom-left'
| 'bottom-right';
/**
* ![Visualization of different append modes](https://wxt.dev/content-script-ui-append.png)
*/
export type ContentScriptAppendMode =
| 'last'
| 'first'
@@ -49,31 +52,46 @@ export function mountContentScriptUiRoot(
}
}
export interface ContentScriptInlinePositioningOptions {
type: 'inline';
}
export interface ContentScriptOverlayPositioningOptions {
type: 'overlay';
/**
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
* content.
*/
zIndex?: number;
/**
* When using `type: "overlay"`, the mounted element is 0px by 0px in size. Alignment specifies
* which corner is aligned with that 0x0 pixel space.
*
* ![Visualization of alignment options](https://wxt.dev/content-script-ui-alignment.png)
*
* @default "top-left"
*/
alignment?: ContentScriptOverlayAlignment;
}
export interface ContentScriptModalPositioningOptions {
type: 'modal';
/**
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
* content.
*/
zIndex?: number;
}
/**
* Choose between `"inline"`, `"overlay"`, or `"modal" `types.
*
* ![Visualization of different types](https://wxt.dev/content-script-ui-type.png)
*/
export type ContentScriptPositioningOptions =
| { type: 'inline' }
| {
type: 'overlay';
/**
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
* content.
*/
zIndex?: number;
/**
* When using `type: "overlay"`, the mounted element is 0px by 0px in size. Alignment specifies
* which corner is aligned with that 0x0 pixel space.
*
* @default "top-left"
*/
alignment?: ContentScriptOverlayAlignment;
}
| {
type: 'modal';
/**
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
* content.
*/
zIndex?: number;
};
| ContentScriptInlinePositioningOptions
| ContentScriptOverlayPositioningOptions
| ContentScriptModalPositioningOptions;
export function applyContentScriptUiPosition(
root: HTMLElement,
+22 -8
View File
@@ -5,6 +5,7 @@ import {
InlineConfig,
InternalConfig,
UserConfig,
VirtualEntrypointType,
WxtBuilder,
WxtBuilderServer,
} from '~/types';
@@ -39,6 +40,10 @@ export async function craeteViteBuilder(
config.build ??= {};
config.build.outDir = wxtConfig.outDir;
config.build.emptyOutDir = false;
// Disable minification for the dev command
if (config.build.minify == null && wxtConfig.command === 'serve') {
config.build.minify = false;
}
config.plugins ??= [];
config.plugins.push(
@@ -46,7 +51,8 @@ export async function craeteViteBuilder(
wxtPlugins.devHtmlPrerender(wxtConfig),
wxtPlugins.unimport(wxtConfig),
wxtPlugins.virtualEntrypoint('background', wxtConfig),
wxtPlugins.virtualEntrypoint('content-script', wxtConfig),
wxtPlugins.virtualEntrypoint('content-script-isolated-world', wxtConfig),
wxtPlugins.virtualEntrypoint('content-script-main-world', wxtConfig),
wxtPlugins.virtualEntrypoint('unlisted-script', wxtConfig),
wxtPlugins.devServerGlobals(wxtConfig),
wxtPlugins.tsconfigPaths(wxtConfig),
@@ -65,13 +71,21 @@ export async function craeteViteBuilder(
* Return the basic config for building an entrypoint in [lib mode](https://vitejs.dev/guide/build.html#library-mode).
*/
const getLibModeConfig = (entrypoint: Entrypoint): vite.InlineConfig => {
const isVirtual = [
'background',
'content-script',
'unlisted-script',
].includes(entrypoint.type);
const entry = isVirtual
? `virtual:wxt-${entrypoint.type}?${entrypoint.inputPath}`
let virtualEntrypointType: VirtualEntrypointType | undefined;
switch (entrypoint.type) {
case 'background':
case 'unlisted-script':
virtualEntrypointType = entrypoint.type;
break;
case 'content-script':
virtualEntrypointType =
entrypoint.options.world === 'MAIN'
? 'content-script-main-world'
: 'content-script-isolated-world';
break;
}
const entry = virtualEntrypointType
? `virtual:wxt-${virtualEntrypointType}?${entrypoint.inputPath}`
: entrypoint.inputPath;
const plugins: NonNullable<vite.UserConfig['plugins']> = [
@@ -1,4 +1,4 @@
import * as vite from 'vite';
import type * as vite from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
let increment = 0;
@@ -1,4 +1,4 @@
import * as vite from 'vite';
import type * as vite from 'vite';
import { Entrypoint, InternalConfig } from '~/types';
import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
@@ -1,4 +1,4 @@
import * as vite from 'vite';
import type * as vite from 'vite';
import { InternalConfig } from '~/types';
import { getEntrypointName } from '~/core/utils/entrypoints';
import { parseHTML } from 'linkedom';
@@ -1,4 +1,4 @@
import * as vite from 'vite';
import type * as vite from 'vite';
import { EntrypointGroup } from '~/types';
import { getEntrypointGlobals } from '~/core/utils/globals';
@@ -1,5 +1,5 @@
import { InternalConfig } from '~/types';
import * as vite from 'vite';
import type * as vite from 'vite';
/**
* Apply the experimental config for disabling the polyfill. It works by aliasing the
+1 -1
View File
@@ -1,4 +1,4 @@
import * as vite from 'vite';
import type * as vite from 'vite';
import { InternalConfig } from '~/types';
import { getGlobals } from '~/core/utils/globals';
+1 -2
View File
@@ -9,7 +9,6 @@ export * from './noopBackground';
export * from './cssEntrypoints';
export * from './bundleAnalysis';
export * from './globals';
export * from './webextensionPolyfillAlias';
export * from './webextensionPolyfillInlineDeps';
export * from './webextensionPolyfillMock';
export * from './excludeBrowserPolyfill';
export * from './entrypointGroupGlobals';
@@ -1,4 +1,4 @@
import * as vite from 'vite';
import type * as vite from 'vite';
import { Entrypoint, InternalConfig } from '~/types';
import { dirname, extname, resolve, join } from 'node:path';
import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
@@ -2,9 +2,8 @@ import { Plugin } from 'vite';
import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '~/core/utils/constants';
/**
* In dev mode, if there's not a background script listed, we need to add one.
*
* This define's a virtual module that is basically just a noop.
* In dev mode, if there's not a background script listed, we need to add one so that the web socket
* connection is setup and the extension reloads HTML pages and content scripts correctly.
*/
export function noopBackground(): Plugin {
const virtualModuleId = VIRTUAL_NOOP_BACKGROUND_MODULE_ID;
@@ -16,7 +15,7 @@ export function noopBackground(): Plugin {
},
load(id) {
if (id === resolvedVirtualModuleId) {
return `import { defineBackground } from 'wxt/client';\nexport default defineBackground(() => void 0)`;
return `import { defineBackground } from 'wxt/sandbox';\nexport default defineBackground(() => void 0)`;
}
},
};
@@ -1,5 +1,5 @@
import { InternalConfig } from '~/types';
import * as vite from 'vite';
import type * as vite from 'vite';
export function tsconfigPaths(
config: Omit<InternalConfig, 'builder'>,
+1 -1
View File
@@ -1,7 +1,7 @@
import { createUnimport } from 'unimport';
import { InternalConfig } from '~/types';
import { getUnimportOptions } from '~/core/utils/unimport';
import * as vite from 'vite';
import type * as vite from 'vite';
import { extname } from 'path';
const ENABLED_EXTENSIONS = new Set([
@@ -1,5 +1,5 @@
import { Plugin } from 'vite';
import { Entrypoint, InternalConfig } from '~/types';
import { InternalConfig, VirtualEntrypointType } from '~/types';
import fs from 'fs-extra';
import { resolve } from 'path';
import { normalizePath } from '~/core/utils/paths';
@@ -8,7 +8,7 @@ import { normalizePath } from '~/core/utils/paths';
* Wraps a user's entrypoint with a vitual version with additional logic.
*/
export function virtualEntrypoint(
type: Entrypoint['type'],
type: VirtualEntrypointType,
config: Omit<InternalConfig, 'builder'>,
): Plugin {
const virtualId = `virtual:wxt-${type}?`;
@@ -1,28 +0,0 @@
import path from 'node:path';
import * as vite from 'vite';
import { InternalConfig } from '~/types';
/**
* Creates an alias to redirect "webextension-polyfill" imports to WXT's `fakeBrowser`.
*
* This should only be used during tests.
*/
export function webextensionPolyfillAlias(
config: Omit<InternalConfig, 'builder'>,
): vite.PluginOption {
return {
name: 'wxt:webextension-polyfill-test-alias',
config() {
return {
resolve: {
alias: {
'webextension-polyfill': path.resolve(
config.root,
'node_modules/wxt/dist/virtual/mock-browser',
),
},
},
};
},
};
}
@@ -1,24 +0,0 @@
import * as vite from 'vite';
/**
* Add all deps that import `webextension-polyfill` to `test.server.deps.inline`.
*
* TODO: Auto-detect non-wxt dependencies via `npm list`.
*/
export function webextensionPolyfillInlineDeps(): vite.PluginOption {
return {
name: 'wxt:testing-inline-deps',
config() {
const wxtModules = ['wxt/browser', 'wxt/client'];
return {
test: {
server: {
deps: {
inline: [...wxtModules],
},
},
},
};
},
};
}
@@ -0,0 +1,41 @@
import path from 'node:path';
import type * as vite from 'vite';
import { InternalConfig } from '~/types';
/**
* Mock `webextension-polyfill` by inlining all dependencies that import it and adding a custom
* alias so that Vite resolves to a mocked version of the module.
*
* There are two ways to mark a module as inline:
* 1. Use partial file paths ("wxt/dist/browser.js") in the `test.server.deps.inline` option.
* 2. Use module names ("wxt" or "@webext-core/messaging") in the `ssr.noExternalize` option.
*
* This plugin uses the second approach since it's a little more intuative to understand.
*
* TODO: Detect non-wxt dependencies (like `@webext-core/*`) that import `webextension-polyfill` via
* `npm list` and inline them automatically.
*/
export function webextensionPolyfillMock(
config: Omit<InternalConfig, 'builder'>,
): vite.PluginOption {
return {
name: 'wxt:testing-inline-deps',
config() {
return {
resolve: {
alias: {
// Alias to use a mocked version of the polyfill
'webextension-polyfill': path.resolve(
config.root,
'node_modules/wxt/dist/virtual/mock-browser',
),
},
},
ssr: {
// Inline all WXT modules
noExternal: ['wxt'],
},
};
},
};
}
-8
View File
@@ -150,14 +150,6 @@ async function cloneProject({
consola.warn('Failed to move _gitignore to .gitignore:', err),
);
// 3. Add .npmrc for pnpm
if (packageManager === 'pnpm') {
await fs.writeFile(
path.join(directory, '.npmrc'),
'shamefully-hoist=true\n',
);
}
spinner.succeed();
} catch (err) {
spinner.fail();
+965
View File
@@ -0,0 +1,965 @@
import { describe, expect, it } from 'vitest';
import { generateManifest, stripPathFromMatchPattern } from '../manifest';
import {
fakeArray,
fakeBackgroundEntrypoint,
fakeBuildOutput,
fakeEntrypoint,
fakeInternalConfig,
fakeOptionsEntrypoint,
fakePopupEntrypoint,
} from '../testing/fake-objects';
import { Manifest } from 'webextension-polyfill';
import {
BuildOutput,
ContentScriptEntrypoint,
Entrypoint,
OutputAsset,
} from '~/types';
const outDir = '/output';
const contentScriptOutDir = '/output/content-scripts';
describe('Manifest Utils', () => {
describe('generateManifest', () => {
describe('popup', () => {
type ActionType = 'browser_action' | 'page_action';
const popupEntrypoint = (type?: ActionType) =>
fakePopupEntrypoint({
options: {
// @ts-expect-error: Force this to be undefined instead of inheriting the random value
mv2Key: type ?? null,
defaultIcon: {
'16': '/icon/16.png',
},
defaultTitle: 'Default Iitle',
},
outputDir: outDir,
});
it('should include an action for mv3', async () => {
const popup = popupEntrypoint();
const buildOutput = fakeBuildOutput();
const config = fakeInternalConfig({
manifestVersion: 3,
outDir,
});
const expected: Partial<Manifest.WebExtensionManifest> = {
action: {
default_icon: popup.options.defaultIcon,
default_title: popup.options.defaultTitle,
default_popup: 'popup.html',
},
};
const actual = await generateManifest([popup], buildOutput, config);
expect(actual).toMatchObject(expected);
});
it.each<{
inputType: ActionType | undefined;
expectedType: ActionType;
}>([
{ inputType: undefined, expectedType: 'browser_action' },
{ inputType: 'browser_action', expectedType: 'browser_action' },
{ inputType: 'page_action', expectedType: 'page_action' },
])(
'should use the correct action for mv2: %j',
async ({ inputType, expectedType }) => {
const popup = popupEntrypoint(inputType);
const buildOutput = fakeBuildOutput();
const config = fakeInternalConfig({
manifestVersion: 2,
outDir,
});
const expected = {
default_icon: popup.options.defaultIcon,
default_title: popup.options.defaultTitle,
default_popup: 'popup.html',
};
const actual = await generateManifest([popup], buildOutput, config);
expect(actual[expectedType]).toEqual(expected);
},
);
});
describe('action without popup', () => {
it('should respect the action field in the manifest without a popup', async () => {
const buildOutput = fakeBuildOutput();
const config = fakeInternalConfig({
outDir,
manifest: {
action: {
default_icon: 'icon-16.png',
default_title: 'Example title',
},
},
});
const expected: Partial<Manifest.WebExtensionManifest> = {
action: config.manifest.action,
};
const actual = await generateManifest([], buildOutput, config);
expect(actual).toMatchObject(expected);
});
});
describe('options', () => {
const options = fakeOptionsEntrypoint({
outputDir: outDir,
options: {
openInTab: false,
chromeStyle: true,
browserStyle: true,
},
});
it('should include a options_ui and chrome_style for chrome', async () => {
const config = fakeInternalConfig({
manifestVersion: 3,
outDir,
browser: 'chrome',
});
const buildOutput = fakeBuildOutput();
const expected = {
open_in_tab: false,
chrome_style: true,
page: 'options.html',
};
const actual = await generateManifest([options], buildOutput, config);
expect(actual.options_ui).toEqual(expected);
});
it('should include a options_ui and browser_style for firefox', async () => {
const config = fakeInternalConfig({
manifestVersion: 3,
browser: 'firefox',
outDir,
});
const buildOutput = fakeBuildOutput();
const expected = {
open_in_tab: false,
browser_style: true,
page: 'options.html',
};
const actual = await generateManifest([options], buildOutput, config);
expect(actual.options_ui).toEqual(expected);
});
});
describe('background', () => {
const background = fakeBackgroundEntrypoint({
outputDir: outDir,
options: {
persistent: true,
type: 'module',
},
});
describe('MV3', () => {
it.each(['chrome', 'safari'])(
'should include a service worker and type for %s',
async (browser) => {
const config = fakeInternalConfig({
outDir,
manifestVersion: 3,
browser,
});
const buildOutput = fakeBuildOutput();
const expected = {
type: 'module',
service_worker: 'background.js',
};
const actual = await generateManifest(
[background],
buildOutput,
config,
);
expect(actual.background).toEqual(expected);
},
);
it('should include a background script and type for firefox', async () => {
const config = fakeInternalConfig({
outDir,
manifestVersion: 3,
browser: 'firefox',
});
const buildOutput = fakeBuildOutput();
const expected = {
type: 'module',
scripts: ['background.js'],
};
const actual = await generateManifest(
[background],
buildOutput,
config,
);
expect(actual.background).toEqual(expected);
});
});
describe('MV2', () => {
it.each(['chrome', 'safari'])(
'should include scripts and persistent for %s',
async (browser) => {
const config = fakeInternalConfig({
outDir,
manifestVersion: 2,
browser,
});
const buildOutput = fakeBuildOutput();
const expected = {
persistent: true,
scripts: ['background.js'],
};
const actual = await generateManifest(
[background],
buildOutput,
config,
);
expect(actual.background).toEqual(expected);
},
);
it('should include a background script and persistent for firefox mv2', async () => {
const config = fakeInternalConfig({
outDir,
manifestVersion: 2,
browser: 'firefox',
});
const buildOutput = fakeBuildOutput();
const expected = {
persistent: true,
scripts: ['background.js'],
};
const actual = await generateManifest(
[background],
buildOutput,
config,
);
expect(actual.background).toEqual(expected);
});
});
});
describe('icons', () => {
it('should auto-discover icons with the correct name', async () => {
const entrypoints = fakeArray(fakeEntrypoint);
const buildOutput = fakeBuildOutput({
publicAssets: [
{ type: 'asset', fileName: 'icon-16.png' },
{ type: 'asset', fileName: 'icon/32.png' },
{ type: 'asset', fileName: 'icon@48w.png' },
{ type: 'asset', fileName: 'icon-64x64.png' },
{ type: 'asset', fileName: 'icon@96.png' },
{ type: 'asset', fileName: 'icons/128x128.png' },
],
});
const config = fakeInternalConfig();
const actual = await generateManifest(entrypoints, buildOutput, config);
expect(actual.icons).toEqual({
16: 'icon-16.png',
32: 'icon/32.png',
48: 'icon@48w.png',
64: 'icon-64x64.png',
96: 'icon@96.png',
128: 'icons/128x128.png',
});
});
it('should return undefined when no icons are found', async () => {
const entrypoints = fakeArray(fakeEntrypoint);
const buildOutput = fakeBuildOutput({
publicAssets: [
{ type: 'asset', fileName: 'logo.png' },
{ type: 'asset', fileName: 'icon-16.jpeg' },
],
});
const config = fakeInternalConfig();
const actual = await generateManifest(entrypoints, buildOutput, config);
expect(actual.icons).toBeUndefined();
});
it('should allow icons to be overwritten from the wxt.config.ts file', async () => {
const entrypoints = fakeArray(fakeEntrypoint);
const buildOutput = fakeBuildOutput({
publicAssets: [
{ type: 'asset', fileName: 'icon-16.png' },
{ type: 'asset', fileName: 'icon-32.png' },
{ type: 'asset', fileName: 'logo-16.png' },
{ type: 'asset', fileName: 'logo-32.png' },
{ type: 'asset', fileName: 'logo-48.png' },
],
});
const expected = {
16: 'logo-16.png',
32: 'logo-32.png',
48: 'logo-48.png',
};
const config = fakeInternalConfig({
manifest: {
icons: expected,
},
});
const actual = await generateManifest(entrypoints, buildOutput, config);
expect(actual.icons).toEqual(expected);
});
});
describe('content_scripts', () => {
it('should group content scripts and styles together based on their manifest properties', async () => {
const cs1: ContentScriptEntrypoint = {
type: 'content-script',
name: 'one',
inputPath: 'entrypoints/one.content/index.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://google.com/*'],
},
};
const cs1Styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/one.css',
};
const cs2: ContentScriptEntrypoint = {
type: 'content-script',
name: 'two',
inputPath: 'entrypoints/two.content/index.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://google.com/*'],
runAt: 'document_end',
},
};
const cs2Styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/two.css',
};
const cs3: ContentScriptEntrypoint = {
type: 'content-script',
name: 'three',
inputPath: 'entrypoints/three.content/index.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://google.com/*'],
runAt: 'document_end',
},
};
const cs3Styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/three.css',
};
const cs4: ContentScriptEntrypoint = {
type: 'content-script',
name: 'four',
inputPath: 'entrypoints/four.content/index.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://duckduckgo.com/*'],
runAt: 'document_end',
},
};
const cs4Styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/four.css',
};
const cs5: ContentScriptEntrypoint = {
type: 'content-script',
name: 'five',
inputPath: 'entrypoints/five.content/index.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://google.com/*'],
world: 'MAIN',
},
};
const cs5Styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/five.css',
};
const entrypoints = [cs1, cs2, cs3, cs4, cs5];
const config = fakeInternalConfig({
command: 'build',
outDir,
manifestVersion: 3,
});
const buildOutput: Omit<BuildOutput, 'manifest'> = {
publicAssets: [],
steps: [
{ entrypoints: cs1, chunks: [cs1Styles] },
{ entrypoints: cs2, chunks: [cs2Styles] },
{ entrypoints: cs3, chunks: [cs3Styles] },
{ entrypoints: cs4, chunks: [cs4Styles] },
{ entrypoints: cs5, chunks: [cs5Styles] },
],
};
const actual = await generateManifest(entrypoints, buildOutput, config);
expect(actual.content_scripts).toContainEqual({
matches: ['*://google.com/*'],
css: ['content-scripts/one.css'],
js: ['content-scripts/one.js'],
});
expect(actual.content_scripts).toContainEqual({
matches: ['*://google.com/*'],
run_at: 'document_end',
css: ['content-scripts/two.css', 'content-scripts/three.css'],
js: ['content-scripts/two.js', 'content-scripts/three.js'],
});
expect(actual.content_scripts).toContainEqual({
matches: ['*://duckduckgo.com/*'],
run_at: 'document_end',
css: ['content-scripts/four.css'],
js: ['content-scripts/four.js'],
});
expect(actual.content_scripts).toContainEqual({
matches: ['*://google.com/*'],
css: ['content-scripts/five.css'],
js: ['content-scripts/five.js'],
world: 'MAIN',
});
});
it('should merge any content scripts declared in wxt.config.ts', async () => {
const cs: ContentScriptEntrypoint = {
type: 'content-script',
name: 'one',
inputPath: 'entrypoints/one.content.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://google.com/*'],
},
};
const generatedContentScript = {
matches: ['*://google.com/*'],
js: ['content-scripts/one.js'],
};
const userContentScript = {
css: ['content-scripts/two.css'],
matches: ['*://*.google.com/*'],
};
const entrypoints = [cs];
const buildOutput = fakeBuildOutput();
const config = fakeInternalConfig({
outDir,
command: 'build',
manifest: {
content_scripts: [userContentScript],
},
});
const actual = await generateManifest(entrypoints, buildOutput, config);
expect(actual.content_scripts).toContainEqual(userContentScript);
expect(actual.content_scripts).toContainEqual(generatedContentScript);
});
describe('cssInjectionMode', () => {
it.each([undefined, 'manifest'] as const)(
'should add a CSS entry when cssInjectionMode is %s',
async (cssInjectionMode) => {
const cs: ContentScriptEntrypoint = {
type: 'content-script',
name: 'one',
inputPath: 'entrypoints/one.content.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://google.com/*'],
cssInjectionMode,
},
};
const styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/one.css',
};
const entrypoints = [cs];
const buildOutput: Omit<BuildOutput, 'manifest'> = {
publicAssets: [],
steps: [{ entrypoints: cs, chunks: [styles] }],
};
const config = fakeInternalConfig({
outDir,
command: 'build',
});
const actual = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.content_scripts).toEqual([
{
js: ['content-scripts/one.js'],
css: ['content-scripts/one.css'],
matches: ['*://google.com/*'],
},
]);
},
);
it.each(['manual', 'ui'] as const)(
'should not add an entry for CSS when cssInjectionMode is %s',
async (cssInjectionMode) => {
const cs: ContentScriptEntrypoint = {
type: 'content-script',
name: 'one',
inputPath: 'entrypoints/one.content.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://google.com/*'],
cssInjectionMode,
},
};
const styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/one.css',
};
const entrypoints = [cs];
const buildOutput: Omit<BuildOutput, 'manifest'> = {
publicAssets: [],
steps: [{ entrypoints: cs, chunks: [styles] }],
};
const config = fakeInternalConfig({
outDir,
command: 'build',
});
const actual = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.content_scripts).toEqual([
{
js: ['content-scripts/one.js'],
matches: ['*://google.com/*'],
},
]);
},
);
it('should add CSS file to `web_accessible_resources` when cssInjectionMode is "ui" for MV3', async () => {
const cs: ContentScriptEntrypoint = {
type: 'content-script',
name: 'one',
inputPath: 'entrypoints/one.content.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://google.com/*'],
cssInjectionMode: 'ui',
},
};
const styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/one.css',
};
const entrypoints = [cs];
const buildOutput: Omit<BuildOutput, 'manifest'> = {
publicAssets: [],
steps: [{ entrypoints: cs, chunks: [styles] }],
};
const config = fakeInternalConfig({
outDir,
command: 'build',
manifestVersion: 3,
});
const actual = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.web_accessible_resources).toEqual([
{
matches: ['*://google.com/*'],
resources: ['content-scripts/one.css'],
},
]);
});
it('should add CSS file to `web_accessible_resources` when cssInjectionMode is "ui" for MV2', async () => {
const cs: ContentScriptEntrypoint = {
type: 'content-script',
name: 'one',
inputPath: 'entrypoints/one.content.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://google.com/*'],
cssInjectionMode: 'ui',
},
};
const styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/one.css',
};
const entrypoints = [cs];
const buildOutput: Omit<BuildOutput, 'manifest'> = {
publicAssets: [],
steps: [{ entrypoints: cs, chunks: [styles] }],
};
const config = fakeInternalConfig({
outDir,
command: 'build',
manifestVersion: 2,
});
const actual = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.web_accessible_resources).toEqual([
'content-scripts/one.css',
]);
});
it('should strip the path off the match pattern so the pattern is valid for `web_accessible_resources`', async () => {
const cs: ContentScriptEntrypoint = {
type: 'content-script',
name: 'one',
inputPath: 'entrypoints/one.content.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://play.google.com/books/*'],
cssInjectionMode: 'ui',
},
};
const styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/one.css',
};
const entrypoints = [cs];
const buildOutput: Omit<BuildOutput, 'manifest'> = {
publicAssets: [],
steps: [{ entrypoints: cs, chunks: [styles] }],
};
const config = fakeInternalConfig({
outDir,
command: 'build',
manifestVersion: 3,
});
const actual = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.web_accessible_resources).toEqual([
{
matches: ['*://play.google.com/*'],
resources: ['content-scripts/one.css'],
},
]);
});
});
});
describe('web_accessible_resources', () => {
it('should combine user defined resources and generated resources for MV3', async () => {
const cs: ContentScriptEntrypoint = {
type: 'content-script',
name: 'one',
inputPath: 'entrypoints/one.content.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://google.com/*'],
cssInjectionMode: 'ui',
},
};
const styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/one.css',
};
const entrypoints = [cs];
const buildOutput: Omit<BuildOutput, 'manifest'> = {
publicAssets: [],
steps: [{ entrypoints: cs, chunks: [styles] }],
};
const config = fakeInternalConfig({
outDir,
command: 'build',
manifestVersion: 3,
manifest: {
web_accessible_resources: [
{ resources: ['one.png'], matches: ['*://one.com/*'] },
],
},
});
const actual = await generateManifest(entrypoints, buildOutput, config);
expect(actual.web_accessible_resources).toEqual([
{ resources: ['one.png'], matches: ['*://one.com/*'] },
{
resources: ['content-scripts/one.css'],
matches: ['*://google.com/*'],
},
]);
});
it('should combine user defined resources and generated resources for MV2', async () => {
const cs: ContentScriptEntrypoint = {
type: 'content-script',
name: 'one',
inputPath: 'entrypoints/one.content.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://google.com/*'],
cssInjectionMode: 'ui',
},
};
const styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/one.css',
};
const entrypoints = [cs];
const buildOutput: Omit<BuildOutput, 'manifest'> = {
publicAssets: [],
steps: [{ entrypoints: cs, chunks: [styles] }],
};
const config = fakeInternalConfig({
outDir,
command: 'build',
manifestVersion: 2,
manifest: {
web_accessible_resources: ['one.png'],
},
});
const actual = await generateManifest(entrypoints, buildOutput, config);
expect(actual.web_accessible_resources).toEqual([
'one.png',
'content-scripts/one.css',
]);
});
});
describe('transformManifest option', () => {
it("should call the transformManifest option after the manifest is generated, but before it's returned", async () => {
const entrypoints: Entrypoint[] = [];
const buildOutput = fakeBuildOutput();
const newAuthor = 'Custom Author';
const config = fakeInternalConfig({
transformManifest(manifest: any) {
manifest.author = newAuthor;
},
});
const expected = {
author: newAuthor,
};
const actual = await generateManifest(entrypoints, buildOutput, config);
expect(actual).toMatchObject(expected);
});
});
describe('version', () => {
it.each(['chrome', 'safari', 'edge'] as const)(
'should include version and version_name as is on %s',
async (browser) => {
const version = '1.0.0';
const versionName = '1.0.0-alpha1';
const entrypoints: Entrypoint[] = [];
const buildOutput = fakeBuildOutput();
const config = fakeInternalConfig({
browser,
manifest: {
version,
version_name: versionName,
},
});
const actual = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.version).toBe(version);
expect(actual.version_name).toBe(versionName);
},
);
it.each(['firefox'] as const)(
'should not include a version_name on %s because it is unsupported',
async (browser) => {
const version = '1.0.0';
const versionName = '1.0.0-alpha1';
const entrypoints: Entrypoint[] = [];
const buildOutput = fakeBuildOutput();
const config = fakeInternalConfig({
browser,
manifest: {
version,
version_name: versionName,
},
});
const actual = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.version).toBe(version);
expect(actual.version_name).toBeUndefined();
},
);
it.each(['chrome', 'firefox', 'safari', 'edge'])(
'should not include the version_name if it is equal to version',
async (browser) => {
const version = '1.0.0';
const entrypoints: Entrypoint[] = [];
const buildOutput = fakeBuildOutput();
const config = fakeInternalConfig({
browser,
manifest: {
version,
version_name: version,
},
});
const actual = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.version).toBe(version);
expect(actual.version_name).toBeUndefined();
},
);
it('should log a warning if the version could not be detected', async () => {
const entrypoints: Entrypoint[] = [];
const buildOutput = fakeBuildOutput();
const config = fakeInternalConfig({
manifest: {
// @ts-ignore: Purposefully removing version from fake object
version: null,
},
});
const actual = await generateManifest(entrypoints, buildOutput, config);
expect(actual.version).toBe('0.0.0');
expect(actual.version_name).toBeUndefined();
expect(config.logger.warn).toBeCalledTimes(1);
expect(config.logger.warn).toBeCalledWith(
expect.stringContaining('Extension version not found'),
);
});
});
describe('commands', () => {
const reloadCommandName = 'wxt:reload-extension';
const reloadCommand = {
suggested_key: {
default: 'Ctrl+E',
},
};
it('should include a command for reloading the extension during development', async () => {
const config = fakeInternalConfig({ command: 'serve' });
const output = fakeBuildOutput();
const entrypoints = fakeArray(fakeEntrypoint);
const actual = await generateManifest(entrypoints, output, config);
expect(actual.commands).toMatchObject({
[reloadCommandName]: reloadCommand,
});
});
it('should not override any existing commands when adding the one to reload the extension', async () => {
const customCommandName = 'custom-command';
const customCommand = {
description: 'Some other command',
suggested_key: {
default: 'Ctrl+H',
},
};
const config = fakeInternalConfig({
command: 'serve',
manifest: {
commands: {
[customCommandName]: customCommand,
},
},
});
const output = fakeBuildOutput();
const entrypoints = fakeArray(fakeEntrypoint);
const actual = await generateManifest(entrypoints, output, config);
expect(actual.commands).toMatchObject({
[reloadCommandName]: reloadCommand,
[customCommandName]: customCommand,
});
});
it('should not include the command when building an extension', async () => {
const config = fakeInternalConfig({ command: 'build' });
const output = fakeBuildOutput();
const entrypoints = fakeArray(fakeEntrypoint);
const actual = await generateManifest(entrypoints, output, config);
expect(actual.commands).toBeUndefined();
});
});
});
describe('stripPathFromMatchPattern', () => {
it.each([
['<all_urls>', '<all_urls>'],
['*://play.google.com/books/*', '*://play.google.com/*'],
['*://*/*', '*://*/*'],
['https://github.com/wxt-dev/*', 'https://github.com/*'],
])('should convert "%s" to "%s"', (input, expected) => {
const actual = stripPathFromMatchPattern(input);
expect(actual).toEqual(expected);
});
});
});
@@ -1,4 +1,4 @@
import { defineBackground } from '~/client';
import { defineBackground } from '~/sandbox';
export default defineBackground({
main() {},
@@ -1,4 +1,4 @@
import { defineContentScript } from '~/client';
import { defineContentScript } from '~/sandbox';
export default defineContentScript({
matches: ['<all_urls>'],
@@ -1,4 +1,4 @@
import { defineBackground } from '~/client';
import { defineBackground } from '~/sandbox';
export const a = {};
@@ -244,15 +244,15 @@ describe('findEntrypoints', () => {
);
it("should include a virtual background script so dev reloading works when there isn't a background entrypoint defined by the user", async () => {
globMock.mockResolvedValueOnce([]);
globMock.mockResolvedValueOnce(['popup.html']);
const entrypoints = await findEntrypoints({
...config,
command: 'serve',
});
expect(entrypoints).toHaveLength(1);
expect(entrypoints[0]).toEqual({
expect(entrypoints).toHaveLength(2);
expect(entrypoints).toContainEqual({
type: 'background',
inputPath: 'virtual:user-background',
name: 'background',
@@ -261,28 +261,24 @@ describe('findEntrypoints', () => {
});
});
it.each<[string, Omit<GenericEntrypoint, 'options'>]>([
[
'injected.ts',
{
type: 'unlisted-script',
name: 'injected',
inputPath: resolve(config.entrypointsDir, 'injected.ts'),
outputDir: config.outDir,
},
],
[
'injected/index.ts',
{
type: 'unlisted-script',
name: 'injected',
inputPath: resolve(config.entrypointsDir, 'injected/index.ts'),
outputDir: config.outDir,
},
],
it.each<string>([
'injected.ts',
'injected.tsx',
'injected.js',
'injected.jsx',
'injected/index.ts',
'injected/index.tsx',
'injected/index.js',
'injected/index.jsx',
])(
'should find and load unlisted-script entrypoint config from %s',
async (path, expected) => {
async (path) => {
const expected = {
type: 'unlisted-script',
name: 'injected',
inputPath: resolve(config.entrypointsDir, path),
outputDir: config.outDir,
};
const options: GenericEntrypoint['options'] = {};
globMock.mockResolvedValueOnce([path]);
importEntrypointFileMock.mockResolvedValue(options);
@@ -566,15 +562,35 @@ describe('findEntrypoints', () => {
});
it('should not allow multiple entrypoints with the same name', async () => {
globMock.mockResolvedValueOnce(['popup.html', 'popup/index.html']);
const expectedPaths = [
'src/entrypoints/popup.html',
'src/entrypoints/popup/index.html',
].map(unnormalizePath);
globMock.mockResolvedValueOnce([
'options/index.html',
'options/index.jsx',
'popup.html',
'popup/index.html',
'popup/index.ts',
'ui.html',
]);
await expect(() => findEntrypoints(config)).rejects.toThrowError(
'Multiple entrypoints with the name "popup" detected, but only one is allowed: ' +
expectedPaths.join(', '),
[
'Multiple entrypoints with the same name detected, only one entrypoint for each name is allowed.',
'',
'- options',
` - ${unnormalizePath('src/entrypoints/options/index.html')}`,
` - ${unnormalizePath('src/entrypoints/options/index.jsx')}`,
'- popup',
` - ${unnormalizePath('src/entrypoints/popup.html')}`,
` - ${unnormalizePath('src/entrypoints/popup/index.html')}`,
` - ${unnormalizePath('src/entrypoints/popup/index.ts')}`,
].join('\n'),
);
});
it('throw an error if there are no entrypoints', async () => {
globMock.mockResolvedValueOnce([]);
await expect(() => findEntrypoints(config)).rejects.toThrowError(
`No entrypoints found in ${unnormalizePath(config.entrypointsDir)}`,
);
});
+103 -84
View File
@@ -31,56 +31,55 @@ import { CSS_EXTENSIONS_PATTERN } from '~/core/utils/paths';
export async function findEntrypoints(
config: InternalConfig,
): Promise<Entrypoint[]> {
const relativePaths = await glob('**/*', {
const relativePaths = await glob(Object.keys(PATH_GLOB_TO_TYPE_MAP), {
cwd: config.entrypointsDir,
});
// Ensure consistent output
relativePaths.sort();
const pathGlobs = Object.keys(PATH_GLOB_TO_TYPE_MAP);
let hasBackground = false;
// TODO: This parallelization is bad
const possibleEntrypoints: Array<Entrypoint | undefined> = await Promise.all(
relativePaths.map(async (relativePath) => {
const path = resolve(config.entrypointsDir, relativePath);
const matchingGlob = pathGlobs.find((glob) =>
minimatch(relativePath, glob),
);
if (matchingGlob == null) {
config.logger.warn(
`${relativePath} does not match any known entrypoint. Known entrypoints:\n${JSON.stringify(
PATH_GLOB_TO_TYPE_MAP,
null,
2,
)}`,
);
return;
}
const entrypointInfos: EntrypointInfo[] = relativePaths.reduce<
EntrypointInfo[]
>((results, relativePath) => {
const inputPath = resolve(config.entrypointsDir, relativePath);
const name = getEntrypointName(config.entrypointsDir, inputPath);
const matchingGlob = pathGlobs.find((glob) =>
minimatch(relativePath, glob),
);
if (matchingGlob) {
const type = PATH_GLOB_TO_TYPE_MAP[matchingGlob];
if (type === 'ignored') return;
results.push({ name, inputPath, type });
}
return results;
}, []);
// Validation
preventNoEntrypoints(config, entrypointInfos);
preventDuplicateEntrypointNames(config, entrypointInfos);
// Import entrypoints to get their config
let hasBackground = false;
const entrypoints: Entrypoint[] = await Promise.all(
entrypointInfos.map(async (info): Promise<Entrypoint> => {
const { type } = info;
switch (type) {
case 'popup':
return await getPopupEntrypoint(config, path);
return await getPopupEntrypoint(config, info);
case 'options':
return await getOptionsEntrypoint(config, path);
return await getOptionsEntrypoint(config, info);
case 'background':
hasBackground = true;
return await getBackgroundEntrypoint(config, path);
return await getBackgroundEntrypoint(config, info);
case 'content-script':
return await getContentScriptEntrypoint(config, path);
return await getContentScriptEntrypoint(config, info);
case 'unlisted-page':
return await getUnlistedPageEntrypoint(config, path);
return await getUnlistedPageEntrypoint(config, info);
case 'unlisted-script':
return await getUnlistedScriptEntrypoint(config, path);
return await getUnlistedScriptEntrypoint(config, info);
case 'content-script-style':
return {
...info,
type,
name: getEntrypointName(config.entrypointsDir, path),
inputPath: path,
outputDir: resolve(config.outDir, CONTENT_SCRIPT_OUT_DIR),
options: {
include: undefined,
@@ -89,9 +88,8 @@ export async function findEntrypoints(
};
default:
return {
...info,
type,
name: getEntrypointName(config.entrypointsDir, path),
inputPath: path,
outputDir: config.outDir,
options: {
include: undefined,
@@ -102,30 +100,13 @@ export async function findEntrypoints(
}),
);
const entrypoints = possibleEntrypoints.filter(
(entry) => !!entry,
) as Entrypoint[];
// Report duplicate entrypoint names
const existingNames: Record<string, Entrypoint | undefined> = {};
entrypoints.forEach((entrypoint) => {
const withSameName = existingNames[entrypoint.name];
if (withSameName) {
throw Error(
`Multiple entrypoints with the name "${
entrypoint.name
}" detected, but only one is allowed: ${[
relative(config.root, withSameName.inputPath),
relative(config.root, entrypoint.inputPath),
].join(', ')}`,
);
}
existingNames[entrypoint.name] = entrypoint;
});
if (config.command === 'serve' && !hasBackground) {
entrypoints.push(
await getBackgroundEntrypoint(config, VIRTUAL_NOOP_BACKGROUND_MODULE_ID),
await getBackgroundEntrypoint(config, {
inputPath: VIRTUAL_NOOP_BACKGROUND_MODULE_ID,
name: 'background',
type: 'background',
}),
);
}
@@ -151,6 +132,50 @@ export async function findEntrypoints(
return targetEntrypoints;
}
interface EntrypointInfo {
name: string;
inputPath: string;
type: Entrypoint['type'];
}
function preventDuplicateEntrypointNames(
config: InternalConfig,
files: EntrypointInfo[],
) {
const namesToPaths = files.reduce<Record<string, string[]>>(
(map, { name, inputPath }) => {
map[name] ??= [];
map[name].push(inputPath);
return map;
},
{},
);
const errorLines = Object.entries(namesToPaths).reduce<string[]>(
(lines, [name, absolutePaths]) => {
if (absolutePaths.length > 1) {
lines.push(`- ${name}`);
absolutePaths.forEach((absolutePath) => {
lines.push(` - ${relative(config.root, absolutePath)}`);
});
}
return lines;
},
[],
);
if (errorLines.length > 0) {
const errorContent = errorLines.join('\n');
throw Error(
`Multiple entrypoints with the same name detected, only one entrypoint for each name is allowed.\n\n${errorContent}`,
);
}
}
function preventNoEntrypoints(config: InternalConfig, files: EntrypointInfo[]) {
if (files.length === 0) {
throw Error(`No entrypoints found in ${config.entrypointsDir}`);
}
}
function getHtmlBaseOptions(document: Document): BaseEntrypointOptions {
const options: BaseEntrypointOptions = {};
@@ -177,9 +202,9 @@ function getHtmlBaseOptions(document: Document): BaseEntrypointOptions {
*/
async function getPopupEntrypoint(
config: InternalConfig,
path: string,
{ inputPath, name }: EntrypointInfo,
): Promise<PopupEntrypoint> {
const content = await fs.readFile(path, 'utf-8');
const content = await fs.readFile(inputPath, 'utf-8');
const { document } = parseHTML(content);
const options: PopupEntrypoint['options'] = getHtmlBaseOptions(document);
@@ -220,7 +245,7 @@ async function getPopupEntrypoint(
type: 'popup',
name: 'popup',
options,
inputPath: path,
inputPath,
outputDir: config.outDir,
};
}
@@ -231,9 +256,9 @@ async function getPopupEntrypoint(
*/
async function getOptionsEntrypoint(
config: InternalConfig,
path: string,
{ inputPath, name }: EntrypointInfo,
): Promise<OptionsEntrypoint> {
const content = await fs.readFile(path, 'utf-8');
const content = await fs.readFile(inputPath, 'utf-8');
const { document } = parseHTML(content);
const options: OptionsEntrypoint['options'] = getHtmlBaseOptions(document);
@@ -263,7 +288,7 @@ async function getOptionsEntrypoint(
type: 'options',
name: 'options',
options,
inputPath: path,
inputPath,
outputDir: config.outDir,
};
}
@@ -274,15 +299,15 @@ async function getOptionsEntrypoint(
*/
async function getUnlistedPageEntrypoint(
config: InternalConfig,
path: string,
{ inputPath, name }: EntrypointInfo,
): Promise<GenericEntrypoint> {
const content = await fs.readFile(path, 'utf-8');
const content = await fs.readFile(inputPath, 'utf-8');
const { document } = parseHTML(content);
return {
type: 'unlisted-page',
name: getEntrypointName(config.entrypointsDir, path),
inputPath: path,
name: getEntrypointName(config.entrypointsDir, inputPath),
inputPath,
outputDir: config.outDir,
options: getHtmlBaseOptions(document),
};
@@ -294,11 +319,10 @@ async function getUnlistedPageEntrypoint(
*/
async function getUnlistedScriptEntrypoint(
config: InternalConfig,
path: string,
{ inputPath, name }: EntrypointInfo,
): Promise<GenericEntrypoint> {
const name = getEntrypointName(config.entrypointsDir, path);
const defaultExport = await importEntrypointFile<UnlistedScriptDefinition>(
path,
inputPath,
config,
);
if (defaultExport == null) {
@@ -311,7 +335,7 @@ async function getUnlistedScriptEntrypoint(
return {
type: 'unlisted-script',
name,
inputPath: path,
inputPath,
outputDir: config.outDir,
options,
};
@@ -322,13 +346,12 @@ async function getUnlistedScriptEntrypoint(
*/
async function getBackgroundEntrypoint(
config: InternalConfig,
path: string,
{ inputPath, name }: EntrypointInfo,
): Promise<BackgroundEntrypoint> {
const name = 'background';
let options: Omit<BackgroundDefinition, 'main'> = {};
if (path !== VIRTUAL_NOOP_BACKGROUND_MODULE_ID) {
if (inputPath !== VIRTUAL_NOOP_BACKGROUND_MODULE_ID) {
const defaultExport = await importEntrypointFile<BackgroundDefinition>(
path,
inputPath,
config,
);
if (defaultExport == null) {
@@ -342,7 +365,7 @@ async function getBackgroundEntrypoint(
return {
type: 'background',
name,
inputPath: path,
inputPath,
outputDir: config.outDir,
options: {
...options,
@@ -357,11 +380,10 @@ async function getBackgroundEntrypoint(
*/
async function getContentScriptEntrypoint(
config: InternalConfig,
path: string,
{ inputPath, name }: EntrypointInfo,
): Promise<ContentScriptEntrypoint> {
const name = getEntrypointName(config.entrypointsDir, path);
const { main: _, ...options } =
await importEntrypointFile<ContentScriptDefinition>(path, config);
await importEntrypointFile<ContentScriptDefinition>(inputPath, config);
if (options == null) {
throw Error(
`${name}: Default export not found, did you forget to call "export default defineContentScript(...)"?`,
@@ -370,13 +392,13 @@ async function getContentScriptEntrypoint(
return {
type: 'content-script',
name,
inputPath: path,
inputPath,
outputDir: resolve(config.outDir, CONTENT_SCRIPT_OUT_DIR),
options,
};
}
const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type'] | 'ignored'> = {
const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type']> = {
'sandbox.html': 'sandbox',
'sandbox/index.html': 'sandbox',
'*.sandbox.html': 'sandbox',
@@ -420,13 +442,10 @@ const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type'] | 'ignored'> = {
'*.html': 'unlisted-page',
'*/index.html': 'unlisted-page',
'*.[jt]s': 'unlisted-script',
'*/index.ts': 'unlisted-script',
'*.[jt]s?(x)': 'unlisted-script',
'*/index.[jt]s?(x)': 'unlisted-script',
[`*.${CSS_EXTENSIONS_PATTERN}`]: 'unlisted-style',
[`*/index.${CSS_EXTENSIONS_PATTERN}`]: 'unlisted-style',
// Don't warn about any files in subdirectories, like CSS or JS entrypoints for HTML files or tests
'*/**': 'ignored',
};
const CONTENT_SCRIPT_OUT_DIR = 'content-scripts';
+1 -1
View File
@@ -180,7 +180,7 @@ async function writeMainDeclarationFile(
filePath,
[
'// Generated by wxt',
`/// <reference types="vite/client" />`,
`/// <reference types="wxt/vite-builder-env" />`,
...references.map(
(ref) =>
`/// <reference types="./${normalizePath(relative(dir, ref))}" />`,
@@ -37,6 +37,9 @@ export async function getInternalConfig(
name: 'wxt',
cwd: inlineConfig.root ?? process.cwd(),
rcFile: false,
jitiOptions: {
esmResolve: true,
},
});
userConfig = loadedConfig ?? {};
userConfigMetadata = metadata;
+39 -24
View File
@@ -2,11 +2,12 @@ import createJITI, { TransformOptions as JitiTransformOptions } from 'jiti';
import { InternalConfig } from '~/types';
import { createUnimport } from 'unimport';
import fs from 'fs-extra';
import { resolve } from 'path';
import { resolve } from 'node:path';
import { getUnimportOptions } from '~/core/utils/unimport';
import { removeProjectImportStatements } from '~/core/utils/strings';
import { normalizePath } from '~/core/utils/paths';
import { TransformOptions, transformSync } from 'esbuild';
import { fileURLToPath } from 'node:url';
/**
* Get the value from the default export of a `path`.
@@ -45,30 +46,44 @@ export async function importEntrypointFile<T>(
['Text:', text, 'No imports:', textNoImports, 'Code:', code].join('\n'),
);
const jiti = createJITI(__filename, {
cache: false,
debug: config.debug,
esmResolve: true,
alias: {
'webextension-polyfill': resolve(
config.root,
'node_modules/wxt/dist/virtual/mock-browser.js',
),
const jiti = createJITI(
typeof __filename !== 'undefined'
? __filename
: fileURLToPath(import.meta.url),
{
cache: false,
debug: config.debug,
esmResolve: true,
alias: {
'webextension-polyfill': resolve(
config.root,
'node_modules/wxt/dist/virtual/mock-browser.js',
),
},
// Continue using node to load TS files even if `bun run --bun` is detected. Jiti does not
// respect the custom transform function when using it's native bun option.
experimentalBun: false,
// List of extensions to transform with esbuild
extensions: [
'.ts',
'.cts',
'.mts',
'.tsx',
'.js',
'.cjs',
'.mjs',
'.jsx',
],
transform(opts) {
const isEntrypoint = opts.filename === normalPath;
return transformSync(
// Use modified source code for entrypoints
isEntrypoint ? code : opts.source,
getEsbuildOptions(opts),
);
},
},
// Continue using node to load TS files even if `bun run --bun` is detected. Jiti does not
// respect the custom transform function when using it's native bun option.
experimentalBun: false,
// List of extensions to transform with esbuild
extensions: ['.ts', '.cts', '.mts', '.tsx', '.js', '.cjs', '.mjs', '.jsx'],
transform(opts) {
const isEntrypoint = opts.filename === normalPath;
return transformSync(
// Use modified source code for entrypoints
isEntrypoint ? code : opts.source,
getEsbuildOptions(opts),
);
},
});
);
try {
const res = await jiti(path);
+3 -3
View File
@@ -1,9 +1,9 @@
import type { Manifest } from 'webextension-polyfill';
import type { Manifest } from '~/browser';
import { BuildOutput, EntrypointGroup, InternalConfig } from '~/types';
import { findEntrypoints } from './find-entrypoints';
import { generateTypesDir } from './generate-wxt-dir';
import { buildEntrypoints } from './build-entrypoints';
import { generateMainfest, writeManifest } from '~/core/utils/manifest';
import { generateManifest, writeManifest } from '~/core/utils/manifest';
/**
* Given a configuration, list of entrypoints, and an existing, partial output, build the
@@ -41,7 +41,7 @@ export async function rebuild(
publicAssets: [...existingOutput.publicAssets, ...newOutput.publicAssets],
};
const newManifest = await generateMainfest(
const newManifest = await generateManifest(
allEntrypoints,
mergedOutput,
config,
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Manifest } from 'webextension-polyfill';
import type { Manifest } from '~/browser';
import { ContentScriptEntrypoint, InternalConfig } from '~/types';
import { resolvePerBrowserOption } from './entrypoints';
+45 -10
View File
@@ -1,4 +1,4 @@
import type { Manifest } from 'webextension-polyfill';
import type { Manifest } from '~/browser';
import {
Entrypoint,
BackgroundEntrypoint,
@@ -50,29 +50,41 @@ export async function writeManifest(
/**
* Generates the manifest based on the config and entrypoints.
*/
export async function generateMainfest(
export async function generateManifest(
entrypoints: Entrypoint[],
buildOutput: Omit<BuildOutput, 'manifest'>,
config: InternalConfig,
): Promise<Manifest.WebExtensionManifest> {
const pkg = await getPackageJson(config);
const versionName = config.manifest.version_name ?? pkg?.version;
const version = config.manifest.version ?? simplifyVersion(pkg?.version);
let versionName =
config.manifest.version_name ?? config.manifest.version ?? pkg?.version;
if (versionName == null) {
versionName = '0.0.0';
config.logger.warn(
'Extension version not found, defaulting to "0.0.0". Add a version to your `package.json` or `wxt.config.ts` file. For more details, see: https://wxt.dev/guide/manifest.html#version-and-version-name',
);
}
const version = config.manifest.version ?? simplifyVersion(versionName);
const baseManifest: Manifest.WebExtensionManifest = {
manifest_version: config.manifestVersion,
name: pkg?.name,
description: pkg?.description,
version,
version_name:
// Firefox doesn't support version_name
config.browser === 'firefox' || versionName === version
? undefined
: versionName,
short_name: pkg?.shortName,
icons: discoverIcons(buildOutput),
};
if (config.command === 'serve') {
baseManifest.commands = {
'wxt:reload-extension': {
description: 'Reload the extension during development',
suggested_key: {
default: 'Ctrl+E',
},
},
};
}
const userManifest = config.manifest;
const manifest = defu(
@@ -80,6 +92,14 @@ export async function generateMainfest(
baseManifest,
) as Manifest.WebExtensionManifest;
// Apply the final version fields after merging the user manifest
manifest.version = version;
manifest.version_name =
// Firefox doesn't support version_name
config.browser === 'firefox' || versionName === version
? undefined
: versionName;
addEntrypoints(manifest, entrypoints, buildOutput, config);
if (config.command === 'serve') addDevModeCsp(manifest, config);
@@ -483,7 +503,10 @@ export function getContentScriptCssWebAccessibleResources(
} else {
resources.push({
resources: [cssFile],
matches: script.options.matches,
matches: resolvePerBrowserOption(
script.options.matches,
config.browser,
).map((matchPattern) => stripPathFromMatchPattern(matchPattern)),
});
}
});
@@ -527,3 +550,15 @@ function addHostPermission(
if (manifest.host_permissions.includes(hostPermission)) return;
manifest.host_permissions.push(hostPermission);
}
/**
* - "<all_urls>" &rarr; "<all_urls>"
* - "*://play.google.com/books/*" &rarr; "*://play.google.com/*"
*/
export function stripPathFromMatchPattern(pattern: string) {
const protocolSepIndex = pattern.indexOf('://');
if (protocolSepIndex === -1) return pattern;
const startOfPath = pattern.indexOf('/', protocolSepIndex + 3);
return pattern.substring(0, startOfPath) + '/*';
}
+1 -2
View File
@@ -22,8 +22,7 @@ export function removeImportStatements(text: string): string {
export function removeProjectImportStatements(text: string): string {
const noImports = removeImportStatements(text);
return `import { defineContentScript, defineBackground } from 'wxt/client';
import { defineUnlistedScript } from 'wxt/sandbox';
return `import { defineUnlistedScript, defineContentScript, defineBackground } from 'wxt/sandbox';
${noImports}`;
}
+37 -8
View File
@@ -4,7 +4,7 @@
import { resolve } from 'path';
import { faker } from '@faker-js/faker';
import merge from 'lodash.merge';
import type { Manifest } from 'webextension-polyfill';
import type { Manifest } from '~/browser';
import {
FsCache,
InternalConfig,
@@ -17,6 +17,9 @@ import {
OutputChunk,
OutputFile,
OutputAsset,
BuildOutput,
BuildStepOutput,
UserManifest,
} from '~/types';
import { mock } from 'vitest-mock-extended';
@@ -39,6 +42,16 @@ export function fakeDir(root = process.cwd()): string {
return resolve(root, faker.string.alphanumeric());
}
export const fakeEntrypoint = () =>
faker.helpers.arrayElement([
fakePopupEntrypoint,
fakeGenericEntrypoint,
fakeOptionsEntrypoint,
fakeBackgroundEntrypoint,
fakeContentScriptEntrypoint,
fakeUnlistedScriptEntrypoint,
])();
export const fakeContentScriptEntrypoint =
fakeObjectCreator<ContentScriptEntrypoint>(() => ({
type: 'content-script',
@@ -65,8 +78,8 @@ export const fakeContentScriptEntrypoint =
export const fakeBackgroundEntrypoint = fakeObjectCreator<BackgroundEntrypoint>(
() => ({
type: 'background',
inputPath: fakeFile('src'),
name: faker.string.alpha(),
inputPath: 'entrypoints/background.ts',
name: 'background',
options: {
persistent: faker.helpers.arrayElement([true, false, undefined]),
type: faker.helpers.maybe(() => 'module'),
@@ -87,8 +100,8 @@ export const fakeUnlistedScriptEntrypoint =
export const fakeOptionsEntrypoint = fakeObjectCreator<OptionsEntrypoint>(
() => ({
type: 'options',
inputPath: fakeFile('src'),
name: faker.string.alpha(),
inputPath: 'entrypoints/options.html',
name: 'options',
outputDir: fakeDir('.output'),
options: {
browserStyle: faker.helpers.arrayElement([true, false, undefined]),
@@ -100,8 +113,8 @@ export const fakeOptionsEntrypoint = fakeObjectCreator<OptionsEntrypoint>(
export const fakePopupEntrypoint = fakeObjectCreator<PopupEntrypoint>(() => ({
type: 'popup',
inputPath: fakeFile('src'),
name: faker.string.alpha(),
inputPath: 'entrypoints/popup.html',
name: 'popup',
outputDir: fakeDir('.output'),
options: {
defaultTitle: faker.helpers.arrayElement([
@@ -165,6 +178,11 @@ export const fakeManifest = fakeObjectCreator<Manifest.WebExtensionManifest>(
}),
);
export const fakeUserManifest = fakeObjectCreator<UserManifest>(() => ({
name: faker.string.alphanumeric(),
version: `${faker.number.int()}.${faker.number.int()}.${faker.number.int()}`,
}));
export function fakeArray<T>(createItem: () => T, count = 3): T[] {
const array: T[] = [];
for (let i = 0; i < count; i++) {
@@ -187,7 +205,7 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
fsCache: mock<FsCache>(),
imports: {},
logger: mock(),
manifest: fakeManifest(),
manifest: fakeUserManifest(),
manifestVersion,
mode,
outBaseDir: fakeDir(),
@@ -222,3 +240,14 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
builder: mock(),
};
});
export const fakeBuildOutput = fakeObjectCreator<BuildOutput>(() => ({
manifest: fakeManifest(),
publicAssets: fakeArray(fakeOutputAsset),
steps: fakeArray(fakeBuildStepOutput),
}));
export const fakeBuildStepOutput = fakeObjectCreator<BuildStepOutput>(() => ({
chunks: fakeArray(fakeOutputChunk),
entrypoints: fakeArray(fakeEntrypoint),
}));
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { defineBackground } from '~/client/define-background';
import { defineBackground } from '~/sandbox/define-background';
import { BackgroundDefinition } from '~/types';
describe('defineBackground', () => {
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { defineContentScript } from '~/client/content-scripts/define-content-script';
import { defineContentScript } from '~/sandbox/define-content-script';
import { ContentScriptDefinition } from '~/types';
describe('defineContentScript', () => {
@@ -1,4 +1,4 @@
import { BackgroundDefinition } from '~/types';
import type { BackgroundDefinition } from '~/types';
export function defineBackground(main: () => void): BackgroundDefinition;
export function defineBackground(
@@ -1,4 +1,4 @@
import { ContentScriptDefinition } from '~/types';
import type { ContentScriptDefinition } from '~/types';
export function defineContentScript(
definition: ContentScriptDefinition,
+1 -1
View File
@@ -1,4 +1,4 @@
import { UnlistedScriptDefinition } from '~/types';
import type { UnlistedScriptDefinition } from '~/types';
export function defineUnlistedScript(
main: () => void,
+5
View File
@@ -1,4 +1,9 @@
/**
* Any runtime APIs that don't use the web extension APIs.
*
* @module wxt/sandbox
*/
export * from './define-unlisted-script';
export * from './define-background';
export * from './define-content-script';
export * from '@webext-core/match-patterns';
@@ -1,3 +1,5 @@
/// <reference types="vite/client" />
function print(method: (...args: any[]) => void, ...args: any[]) {
if (import.meta.env.MODE === 'production') return;
+572 -101
View File
@@ -1,118 +1,589 @@
/**
* Simplfied storage APIs with support for versioned fields, snapshots, metadata, and item definitions.
*
* See [the guide](https://wxt.dev/guide/storage.html) for more information.
*
* @module wxt/storage
*/
import {
Driver,
WatchCallback,
createStorage,
defineDriver,
Storage,
} from 'unstorage';
import browser, { Storage as BrowserStorage } from 'webextension-polyfill';
import { Storage, browser } from '~/browser';
import { dequal } from 'dequal/lite';
export interface WebExtensionDriverOptions {
storageArea: 'sync' | 'local' | 'managed' | 'session';
}
export const storage = createStorage();
export const webExtensionDriver: (opts: WebExtensionDriverOptions) => Driver =
defineDriver((opts) => {
const checkPermission = () => {
if (browser.storage == null)
throw Error(
"You must request the 'storage' permission to use webExtensionDriver",
);
};
const _storageListener: (
changes: BrowserStorage.StorageAreaSyncOnChangedChangesType,
) => void = (changes) => {
Object.entries(changes).forEach(([key, { newValue }]) => {
_listeners.forEach((callback) => {
callback(newValue ? 'update' : 'remove', key);
});
});
};
const _listeners = new Set<WatchCallback>();
function createStorage(): WxtStorage {
const drivers: Record<string, WxtStorageDriver> = {
local: createDriver('local'),
session: createDriver('session'),
sync: createDriver('sync'),
managed: createDriver('managed'),
};
const getDriver = (area: string) => {
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: string) => {
const deliminatorIndex = key.indexOf(':');
const driverArea = key.substring(0, deliminatorIndex);
const driverKey = key.substring(deliminatorIndex + 1);
if (driverKey == null)
throw Error(
`Storage key should be in the form of "area:key", but recieved "${key}"`,
);
return {
name: 'web-extension:' + opts.storageArea,
async hasItem(key) {
checkPermission();
const res = await browser.storage[opts.storageArea].get(key);
return res[key] != null;
},
async getItem(key) {
checkPermission();
const res = await browser.storage[opts.storageArea].get(key);
return res[key] ?? null;
},
async getItems(items) {
checkPermission();
const res = await browser.storage[opts.storageArea].get(
items.map((item) => item.key),
driverArea,
driverKey,
driver: getDriver(driverArea),
};
};
const getMetaKey = (key: string) => key + '$';
const getValueOrDefault = (value: any, defaultValue: any) =>
value ?? defaultValue ?? 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 getValueOrDefault(res, 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));
const newFields = { ...existingFields };
Object.entries(properties).forEach(([key, value]) => {
if (value == null) {
delete newFields[key];
} else {
newFields[key] = value;
}
});
await driver.setItem(metaKey, newFields);
};
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<string, string[]>();
const keyToOptsMap = new Map<string, GetItemOptions<any> | undefined>();
keys.forEach((key) => {
let keyStr: string;
let opts: GetItemOptions<any> | undefined;
if (typeof key === 'string') {
keyStr = key;
} else {
keyStr = key.key;
opts = key.options;
}
const { driverArea, driverKey } = resolveKey(keyStr);
const keys = areaToKeyMap.get(driverArea) ?? [];
areaToKeyMap.set(driverArea, keys.concat(driverKey));
keyToOptsMap.set(keyStr, opts);
});
const results = await Promise.all(
Array.from(areaToKeyMap.entries()).map(async ([driverArea, keys]) => {
const driverResults = await drivers[driverArea].getItems(keys);
return driverResults.map((driverResult) => {
const key = `${driverArea}:${driverResult.key}`;
const value = getValueOrDefault(
driverResult.value,
keyToOptsMap.get(key)?.defaultValue,
);
return { key, value };
});
}),
);
return results.flat();
},
getMeta: async (key) => {
const { driver, driverKey } = resolveKey(key);
return await getMeta(driver, driverKey);
},
setItem: async (key, value) => {
const { driver, driverKey } = resolveKey(key);
await setItem(driver, driverKey, value);
},
setItems: async (values) => {
const areaToKeyValueMap = new Map<
string,
Array<{ key: string; value: any }>
>();
values.forEach(({ key, value }) => {
const { driverArea, driverKey } = resolveKey(key);
const values = areaToKeyValueMap.get(driverArea) ?? [];
areaToKeyValueMap.set(
driverArea,
values.concat({ key: driverKey, value }),
);
return items.map((item) => ({
key: item.key,
value: res[item.key] ?? null,
}));
},
async setItem(key, value) {
checkPermission();
await browser.storage[opts.storageArea].set({ [key]: value ?? null });
},
async setItems(items) {
checkPermission();
const map = items.reduce<Record<string, any>>((map, item) => {
map[item.key] = item.value ?? null;
return map;
}, {});
await browser.storage[opts.storageArea].set(map);
},
async removeItem(key) {
checkPermission();
await browser.storage[opts.storageArea].remove(key);
},
async getKeys() {
checkPermission();
const all = await browser.storage[opts.storageArea].get();
return Object.keys(all);
},
async clear() {
checkPermission();
await browser.storage[opts.storageArea].clear();
},
watch(callback) {
checkPermission();
_listeners.add(callback);
if (_listeners.size === 1) {
browser.storage[opts.storageArea].onChanged.addListener(
_storageListener,
});
await Promise.all(
Array.from(areaToKeyValueMap.entries()).map(
async ([driverArea, values]) => {
const driver = getDriver(driverArea);
await driver.setItems(values);
},
),
);
},
setMeta: async (key, properties) => {
const { driver, driverKey } = resolveKey(key);
await setMeta(driver, driverKey, properties);
},
removeItem: async (key, opts) => {
const { driver, driverKey } = resolveKey(key);
await removeItem(driver, driverKey, opts);
},
removeItems: async (keys) => {
const areaToKeysMap = new Map<string, string[]>();
keys.forEach((key) => {
let keyStr: string;
let opts: RemoveItemOptions | undefined;
if (typeof key === 'string') {
keyStr = key;
} else {
keyStr = key.key;
opts = key.options;
}
const { driverArea, driverKey } = resolveKey(keyStr);
const areaKeys = areaToKeysMap.get(driverArea) ?? [];
areaKeys.push(driverKey);
if (opts?.removeMeta) {
areaKeys.push(getMetaKey(driverKey));
}
areaToKeysMap.set(driverArea, areaKeys);
});
await Promise.all(
Array.from(areaToKeysMap.entries()).map(async ([driverArea, keys]) => {
const driver = getDriver(driverArea);
await driver.removeItems(keys);
}),
);
},
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) => {
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 runMigrations = async () => {
const [value, meta] = await Promise.all([
// TODO: Optimize with getItems
getItem(driver, driverKey, undefined),
getMeta(driver, driverKey),
]);
if (value == null) return;
const currentVersion = meta.v ?? 1;
if (currentVersion > targetVersion) {
throw Error(
`[wxt/storage] Migration ignored for "${key}", version downgrade detected (${currentVersion} -> ${targetVersion})`,
);
}
return () => {
_listeners.delete(callback);
if (_listeners.size === 0) {
browser.storage[opts.storageArea].onChanged.removeListener(
_storageListener,
);
}
};
},
};
});
const migrationsToRun = Array.from(
{ length: targetVersion - currentVersion },
(_, i) => currentVersion + i + 1,
);
let migratedValue = value;
for (const migrateToVersion of migrationsToRun) {
migratedValue =
(await migrations?.[migrateToVersion]?.(migratedValue)) ??
migratedValue;
}
await Promise.all([
// TODO: Optimize with `setItem`
setItem(driver, driverKey, migratedValue),
setMeta(driver, driverKey, { v: targetVersion }),
]);
};
let _migrationsCompleted = runMigrations();
function createWebExtensionStorage() {
const storage = createStorage();
storage.mount('local', webExtensionDriver({ storageArea: 'local' }));
storage.mount('session', webExtensionDriver({ storageArea: 'session' }));
storage.mount('sync', webExtensionDriver({ storageArea: 'sync' }));
storage.mount('managed', webExtensionDriver({ storageArea: 'managed' }));
return {
_migrationsCompleted,
getValue: () => getItem(driver, driverKey, opts),
getMeta: () => getMeta(driver, driverKey),
setValue: (value) => setItem(driver, driverKey, value),
setMeta: (properties) => setMeta(driver, driverKey, properties),
removeValue: (opts) => removeItem(driver, driverKey, opts),
removeMeta: (properties) => removeMeta(driver, driverKey, properties),
watch: (cb) => watch(driver, driverKey, cb),
};
},
};
return storage;
}
export type StorageValue = null | string | number | boolean | object;
function createDriver(
storageArea: 'local' | 'session' | 'sync' | 'managed',
): WxtStorageDriver {
const getStorageArea = () => {
if (browser.storage == null)
throw Error(
"You must add the 'storage' permission to your manifest to use 'wxt/storage'",
);
export const storage: Storage<StorageValue> = createWebExtensionStorage();
return browser.storage[storageArea];
};
const watchListeners = new Set<
(changes: Storage.StorageAreaOnChangedChangesType) => 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);
},
snapshot: async () => {
return await getStorageArea().get();
},
restoreSnapshot: async (data) => {
await getStorageArea().set(data);
},
watch(key, cb) {
const listener = (changes: Storage.StorageAreaOnChangedChangesType) => {
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 * from 'unstorage';
export interface WxtStorage {
/**
* Get an item from storage, or return `null` if it doesn't exist.
*
* @example
* await storage.getItem<number>("local:installDate");
*/
getItem<T>(key: string, opts?: GetItemOptions<T>): Promise<T | null>;
/**
* Get multiple items from storage. There is no guarentee of order in the returned array.
*
* @example
* await storage.getItems(["local:installDate", "session:someCounter"]);
*/
getItems(
keys: Array<string | { key: string; options?: GetItemOptions<any> }>,
): Promise<Array<{ key: string; 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: string): Promise<T>;
/**
* 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: string, 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: string; 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: string,
properties: T | null,
): Promise<void>;
/**
* Removes an item from storage.
*
* @example
* await storage.removeItem("local:installDate");
*/
removeItem(key: string, opts?: RemoveItemOptions): Promise<void>;
/**
* Remove a list of keys from storage.
*/
removeItems(
keys: Array<string | { key: string; options?: RemoveItemOptions }>,
): 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: string, properties?: string | string[]): Promise<void>;
/**
* Return all the items in storage.
*/
snapshot(
base: string,
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 overritten.
*/
restoreSnapshot(base: string, data: any): Promise<void>;
/**
* Watch for changes to a specific key in storage.
*/
watch<T>(key: string, cb: WatchCallback<T>): Unwatch;
/**
* Remove all watch listeners.
*/
unwatch(): void;
/**
* Define a constant with utilities for reading/writing to a single value in storage.
*
* @example
* export const installDate = storage.defineItem<number>("local:installDate");
*/
defineItem<TValue, TMetadata extends Record<string, unknown> = {}>(
key: string,
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>;
snapshot(): Promise<Record<string, unknown>>;
restoreSnapshot(data: Record<string, unknown>): Promise<void>;
watch<T>(key: string, cb: WatchCallback<T>): Unwatch;
unwatch(): void;
}
export interface WxtStorageItem<
TValue,
TMetadata extends Record<string, unknown>,
> {
/**
* Get the latest value from storage.
*/
getValue(): Promise<TValue>;
/**
* Get metadata.
*/
getMeta(): Promise<NullablePartial<TMetadata>>;
/**
* Set the value in storage.
*/
setValue(value: TValue | null): 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;
}
export interface GetItemOptions<T> {
/**
* Value returned from `getValue` when it would otherwise return null.
*/
defaultValue?: 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> extends GetItemOptions<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>;
}
/**
* Same as `Partial`, but includes `| null`. It makes all the properties of an object optional and
* nullable.
*/
export 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 | null, oldValue: T | null) => void;
/**
* Call to remove a watch listener
*/
export type Unwatch = () => void;
+3 -5
View File
@@ -1,11 +1,10 @@
import * as vite from 'vite';
import type * as vite from 'vite';
import {
unimport,
download,
tsconfigPaths,
globals,
webextensionPolyfillAlias,
webextensionPolyfillInlineDeps,
webextensionPolyfillMock,
} from '../core/builders/vite/plugins';
import { getInternalConfig } from '~/core/utils/building';
import { InlineConfig } from '../types';
@@ -27,8 +26,7 @@ import { InlineConfig } from '../types';
*/
export function WxtVitest(inlineConfig?: InlineConfig): vite.PluginOption {
return getInternalConfig(inlineConfig ?? {}, 'serve').then((config) => [
webextensionPolyfillAlias(config),
webextensionPolyfillInlineDeps(),
webextensionPolyfillMock(config),
unimport(config),
globals(config),
download(config),
+31 -11
View File
@@ -1,5 +1,5 @@
import type * as vite from 'vite';
import type { Manifest, Scripting } from 'webextension-polyfill';
import type { Manifest, Scripting } from '~/browser';
import { UnimportOptions } from 'unimport';
import { LogLevel } from 'consola';
import { ContentScriptContext } from '../client/content-scripts/content-script-context';
@@ -439,7 +439,36 @@ export type EntrypointGroup = Entrypoint | Entrypoint[];
export type OnContentScriptStopped = (cb: () => void) => void;
export interface ContentScriptDefinition extends ExcludableEntrypoint {
export type ContentScriptDefinition =
| ContentScriptIsolatedWorldDefinition
| ContentScriptMainWorldDefinition;
export interface ContentScriptIsolatedWorldDefinition
extends ContentScriptBaseDefinition {
/**
* See https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts#isolated_world
* @default "ISOLATED"
*/
world?: 'ISOLATED';
/**
* Main function executed when the content script is loaded.
*/
main(ctx: ContentScriptContext): void | Promise<void>;
}
export interface ContentScriptMainWorldDefinition
extends ContentScriptBaseDefinition {
/**
* See https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts#isolated_world
*/
world: 'MAIN';
/**
* Main function executed when the content script is loaded.
*/
main(): void | Promise<void>;
}
export interface ContentScriptBaseDefinition extends ExcludableEntrypoint {
matches: PerBrowserOption<Manifest.ContentScript['matches']>;
/**
* See https://developer.chrome.com/docs/extensions/mv3/content_scripts/
@@ -478,11 +507,6 @@ export interface ContentScriptDefinition extends ExcludableEntrypoint {
* @default false
*/
matchOriginAsFallback?: PerBrowserOption<boolean>;
/**
* See https://developer.chrome.com/docs/extensions/mv3/content_scripts/
* @default "ISOLATED"
*/
world?: PerBrowserOption<'ISOLATED' | 'MAIN'>;
/**
* Customize how imported/generated styles are injected with the content script. Regardless of the
* mode selected, CSS will always be built and included in the output directory.
@@ -497,10 +521,6 @@ export interface ContentScriptDefinition extends ExcludableEntrypoint {
* @default "manifest"
*/
cssInjectionMode?: PerBrowserOption<'manifest' | 'manual' | 'ui'>;
/**
* Main function executed when the content script is loaded.
*/
main(ctx: ContentScriptContext): void | Promise<void>;
}
export interface BackgroundDefinition extends ExcludableEntrypoint {
+7
View File
@@ -0,0 +1,7 @@
// Globals defined by the vite-plugins/devServerGlobals.ts and utils/globals.ts
declare const __COMMAND__: 'build' | 'serve';
declare const __DEV_SERVER_PROTOCOL__: string;
declare const __DEV_SERVER_HOSTNAME__: string;
declare const __DEV_SERVER_PORT__: string;
declare const __MANIFEST_VERSION__: 2 | 3;
declare const __ENTRYPOINT__: string;
+7 -1
View File
@@ -11,7 +11,7 @@ import {
} from './external';
import { UnimportOptions } from 'unimport';
import { ResolvedConfig } from 'c12';
import type { Manifest } from 'webextension-polyfill';
import type { Manifest } from '~/browser';
import type { PluginVisualizerOptions } from 'rollup-plugin-visualizer';
export interface InternalConfig {
@@ -67,3 +67,9 @@ export interface ExtensionRunner {
openBrowser(config: InternalConfig): Promise<void>;
closeBrowser(): Promise<void>;
}
export type VirtualEntrypointType =
| 'content-script-main-world'
| 'content-script-isolated-world'
| 'background'
| 'unlisted-script';
+11
View File
@@ -0,0 +1,11 @@
# WXT Virtual Entrypoints
This folder contains scripts that are either loaded as entrypoints to JS files or included in HTML files, just like a project using WXT might load their own scripts.
While they are bundled and shipped inside WXT, Vite considers them apart of your project's source code, not WXT's. This means they cannot import 3rd party modules directly, otherwise `pnpm i --shamefully-hoist=false` and Yarn PnP will fail.
For this reason, the virtual entrypoints get their own TS project to isolate them from the rest of the project. They can only import from `wxt/*` or utils that don't have any imports from node_modules, like the logger.
When bundling WXT for publishing to NPM, all the `wxt/*` imports are marked as external and resolved when building your application. Other imports are added inline.
See https://github.com/wxt-dev/wxt/issues/286#issuecomment-1858888390 for more details.
+11 -5
View File
@@ -1,9 +1,9 @@
import definition from 'virtual:user-background';
import { setupWebSocket } from '../client/utils/setup-web-socket';
import { logger } from '../client/utils/logger';
import browser from 'webextension-polyfill';
import { keepServiceWorkerAlive } from '../client/utils/keep-service-worker-alive';
import { reloadContentScript } from '../client/utils/reload-content-scripts';
import { setupWebSocket } from './utils/setup-web-socket';
import { logger } from '../sandbox/utils/logger';
import { browser } from 'wxt/browser';
import { keepServiceWorkerAlive } from './utils/keep-service-worker-alive';
import { reloadContentScript } from './utils/reload-content-scripts';
if (__COMMAND__ === 'serve') {
try {
@@ -26,6 +26,12 @@ if (__COMMAND__ === 'serve') {
} catch (err) {
logger.error('Failed to setup web socket connection with dev server', err);
}
browser.commands.onCommand.addListener((command) => {
if (command === 'wxt:reload-extension') {
browser.runtime.reload();
}
});
}
try {
@@ -1,6 +1,6 @@
import definition from 'virtual:user-content-script';
import { logger } from '~/client/utils/logger';
import { ContentScriptContext } from '~/client/content-scripts/content-script-context';
import definition from 'virtual:user-content-script-isolated-world';
import { logger } from '../sandbox/utils/logger';
import { ContentScriptContext } from 'wxt/client';
(async () => {
try {
@@ -0,0 +1,14 @@
import definition from 'virtual:user-content-script-main-world';
import { logger } from '../sandbox/utils/logger';
(async () => {
try {
const { main } = definition;
await main();
} catch (err) {
logger.error(
`The content script "${__ENTRYPOINT__}" crashed on startup!`,
err,
);
}
})();
+1 -1
View File
@@ -1,3 +1,3 @@
import { fakeBrowser as mockBrowser } from '~/testing';
import { fakeBrowser as mockBrowser } from 'wxt/testing';
export default mockBrowser;

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