Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e60f37ec5 | |||
| 6f901b32eb | |||
| 927aca612e | |||
| 2dcdae91eb | |||
| eb758bdeb8 | |||
| 5acf51af9d | |||
| f9718a1b71 | |||
| cf7f23d4c3 | |||
| 62ecb6f41e | |||
| fc45c37f22 | |||
| 68874e607e | |||
| 35865adcb2 | |||
| 3b63a51475 | |||
| d32e59e38f | |||
| dab8efa140 | |||
| c298d8552b | |||
| 87267347f3 | |||
| 326e84b667 | |||
| 4b005b450e | |||
| 3bf40fc4ed | |||
| 9ace395e11 | |||
| 64f42064ae | |||
| a525d786f1 | |||
| 948ee810da | |||
| 8687b2980e | |||
| 6f0814bc97 | |||
| 148c0da51f | |||
| ba09f3e0f1 | |||
| 54e24652da | |||
| d6516142d2 | |||
| 460667e3c0 | |||
| 6ed61d0766 | |||
| 971daf2784 | |||
| 89d8ef9a50 | |||
| a329e24818 | |||
| 249cf63da8 | |||
| 70a496108a | |||
| 214889e052 | |||
| b6ab7a9567 | |||
| 1ceb9e2e80 | |||
| 15ea81514f | |||
| 21d4e6dede | |||
| 5343430c0f | |||
| 8c834835c4 | |||
| 9a2cc18f64 | |||
| 8683bd4b77 | |||
| dca52864bb | |||
| 0c5643f648 | |||
| b7cdf1597c | |||
| 2770974261 | |||
| ab6a34afb6 | |||
| b0eb7da9b4 | |||
| 4f649fc872 | |||
| 1af64aa02f | |||
| c35acff259 | |||
| 995d4f8080 | |||
| b7e2aa3d8f | |||
| e347f4529f | |||
| 3d9683418d | |||
| bdce363eed | |||
| e7775f291a | |||
| 59f6a28c65 | |||
| f7534464bc | |||
| 01445fb1da | |||
| 1360eb7c01 | |||
| c99a281169 | |||
| 76e63e24c0 | |||
| f5ba1173da | |||
| 12079e6ffc | |||
| ff6740d429 |
@@ -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
|
||||
|
||||
|
||||
@@ -30,10 +30,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup
|
||||
- run: pnpm build:all
|
||||
working-directory: demo
|
||||
- run: pnpm tsc --noEmit
|
||||
working-directory: demo
|
||||
- run: pnpm build:all
|
||||
working-directory: demo
|
||||
- run: pnpm wxt zip
|
||||
working-directory: demo
|
||||
- run: pnpm vitest run
|
||||
|
||||
+250
@@ -1,5 +1,255 @@
|
||||
# Changelog
|
||||
|
||||
## v0.15.0
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.7...v0.15.0)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- **zip:** ⚠️ Add `includeSources` and rename `ignoredSources` to `excludeSources` ([#378](https://github.com/wxt-dev/wxt/pull/378))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Generate missing sourcemap in `wxt:unimport` plugin ([#381](https://github.com/wxt-dev/wxt/pull/381))
|
||||
- ⚠️ Move browser constants to `import.meta.env` ([#380](https://github.com/wxt-dev/wxt/pull/380))
|
||||
- Enable inline sourcemaps by default during development ([#382](https://github.com/wxt-dev/wxt/pull/382))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Fix typo ([f9718a1](https://github.com/wxt-dev/wxt/commit/f9718a1))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Update contributor docs ([eb758bd](https://github.com/wxt-dev/wxt/commit/eb758bd))
|
||||
|
||||
#### ⚠️ Breaking Changes
|
||||
|
||||
- **zip:** ⚠️ Add `includeSources` and rename `ignoredSources` to `excludeSources` ([#378](https://github.com/wxt-dev/wxt/pull/378))
|
||||
- ⚠️ Move browser constants to `import.meta.env` ([#380](https://github.com/wxt-dev/wxt/pull/380))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Nenad Novaković
|
||||
|
||||
## v0.14.7
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.6...v0.14.7)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Improve error messages when importing and building entrypoints ([3b63a51](https://github.com/wxt-dev/wxt/commit/3b63a51))
|
||||
- **storage:** Throw better error message when importing outside a extension environment ([35865ad](https://github.com/wxt-dev/wxt/commit/35865ad))
|
||||
- Upgrade `web-ext-run` ([62ecb6f](https://github.com/wxt-dev/wxt/commit/62ecb6f))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Add `matches` to content script examples ([dab8efa](https://github.com/wxt-dev/wxt/commit/dab8efa))
|
||||
- Fix incorrect sample code ([#372](https://github.com/wxt-dev/wxt/pull/372))
|
||||
- Document defined constants for the build target ([68874e6](https://github.com/wxt-dev/wxt/commit/68874e6))
|
||||
- Add missing `await` to `createShadowRootUi` examples ([fc45c37](https://github.com/wxt-dev/wxt/commit/fc45c37))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- 東奈比 ([@dongnaebi](http://github.com/dongnaebi))
|
||||
|
||||
## v0.14.6
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.5...v0.14.6)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Restart dev mode when saving config ([#365](https://github.com/wxt-dev/wxt/pull/365))
|
||||
- Add basic validation for entrypoint options ([#368](https://github.com/wxt-dev/wxt/pull/368))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Add subdependency bin directory so `wxt build --analyze` works with PNPM ([#363](https://github.com/wxt-dev/wxt/pull/363))
|
||||
- Sort build output files naturally ([#364](https://github.com/wxt-dev/wxt/pull/364))
|
||||
|
||||
### 🤖 CI
|
||||
|
||||
- Check for type errors in demo before building ([4b005b4](https://github.com/wxt-dev/wxt/commit/4b005b4))
|
||||
|
||||
## v0.14.5
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.4...v0.14.5)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Add `dev.reloadCommand` config ([#362](https://github.com/wxt-dev/wxt/pull/362))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Disable reload dev command when 4 commands are already registered ([#361](https://github.com/wxt-dev/wxt/pull/361))
|
||||
|
||||
## v0.14.4
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.3...v0.14.4)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Allow requiring built-in node modules from ESM CLI ([#356](https://github.com/wxt-dev/wxt/pull/356))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Add unit tests for passing flags via the CLI ([#354](https://github.com/wxt-dev/wxt/pull/354))
|
||||
|
||||
## v0.14.3
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.2...v0.14.3)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Make `getArrayFromFlags` result can be undefined ([#352](https://github.com/wxt-dev/wxt/pull/352))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Yuns ([@yunsii](http://github.com/yunsii))
|
||||
|
||||
## v0.14.2
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.1...v0.14.2)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Add `filterEntrypoints` option to speed up development ([#344](https://github.com/wxt-dev/wxt/pull/344))
|
||||
|
||||
### 🔥 Performance
|
||||
|
||||
- Only call `findEntrypoint` once per build ([#342](https://github.com/wxt-dev/wxt/pull/342))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Improve error message and document use of imported variables outside an entrypoint's `main` function ([#346](https://github.com/wxt-dev/wxt/pull/346))
|
||||
- Allow `browser.runtime.getURL` to include hashes and query params for HTML paths ([#350](https://github.com/wxt-dev/wxt/pull/350))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Fix typos and outdated ui function usage ([#347](https://github.com/wxt-dev/wxt/pull/347))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Update templates to `^0.14.0` ([70a4961](https://github.com/wxt-dev/wxt/commit/70a4961))
|
||||
- Fix typo in function name ([a329e24](https://github.com/wxt-dev/wxt/commit/a329e24))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Yuns ([@yunsii](http://github.com/yunsii))
|
||||
- Armin
|
||||
|
||||
## v0.14.1
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.0...v0.14.1)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Use `Alt+R`/`Opt+R` to reload extension during development ([b6ab7a9](https://github.com/wxt-dev/wxt/commit/b6ab7a9))
|
||||
|
||||
## v0.14.0
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.5...v0.14.0)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- ⚠️ Refactor content script UI functions and add helper for "integrated" UIs ([#333](https://github.com/wxt-dev/wxt/pull/333))
|
||||
|
||||
#### ⚠️ Breaking Changes
|
||||
|
||||
`createContentScriptUi` and `createContentScriptIframe`, and some of their options, have been renamed:
|
||||
|
||||
- `createContentScriptUi({ ... })` → `createShadowRootUi({ ... })`
|
||||
- `createContentScriptIframe({ ... })` → `createIframeUi({ ... })`
|
||||
- `type: "inline" | "overlay" | "modal"` has been changed to `position: "inline" | "overlay" | "modal"`
|
||||
- `onRemove` is now called ***before*** the UI is removed from the DOM, previously it was called after the UI was removed
|
||||
- `mount` option has been renamed to `onMount`, to better match the related option, `onRemove`.
|
||||
|
||||
## v0.13.5
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.4...v0.13.5)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Strip path from `web_accessible_resources[0].matches` ([#332](https://github.com/wxt-dev/wxt/pull/332))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Add section about customizing other browser options during development ([8683bd4](https://github.com/wxt-dev/wxt/commit/8683bd4))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Update bug report template ([9a2cc18](https://github.com/wxt-dev/wxt/commit/9a2cc18))
|
||||
|
||||
## v0.13.4
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.3...v0.13.4)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Disable minification during development ([b7cdf15](https://github.com/wxt-dev/wxt/commit/b7cdf15))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Use `const` instead of `let` ([2770974](https://github.com/wxt-dev/wxt/commit/2770974))
|
||||
|
||||
## v0.13.3
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.2...v0.13.3)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- **DX:** Add `ctrl+E`/`cmd+E` shortcut to reload extension during development ([#322](https://github.com/wxt-dev/wxt/pull/322))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- **deps-dev:** Bump tsx from 4.6.2 to 4.7.0 ([#320](https://github.com/wxt-dev/wxt/pull/320))
|
||||
- **deps-dev:** Bump prettier from 3.1.0 to 3.1.1 ([#318](https://github.com/wxt-dev/wxt/pull/318))
|
||||
- **deps-dev:** Bump vitepress from 1.0.0-rc.31 to 1.0.0-rc.34 ([#316](https://github.com/wxt-dev/wxt/pull/316))
|
||||
- Refactor manifest generation E2E tests to unit tests ([#323](https://github.com/wxt-dev/wxt/pull/323))
|
||||
|
||||
## v0.13.2
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.1...v0.13.2)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Add `isolateEvents` option to `createContentScripUi` ([#313](https://github.com/wxt-dev/wxt/pull/313))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Remove duplicate `entrypoints/` path ([76e63e2](https://github.com/wxt-dev/wxt/commit/76e63e2))
|
||||
- Update unlisted pages/scripts description ([c99a281](https://github.com/wxt-dev/wxt/commit/c99a281))
|
||||
- Update content script entrypoint docs ([1360eb7](https://github.com/wxt-dev/wxt/commit/1360eb7))
|
||||
- Add example for setting up custom panels/panes in devtools ([#308](https://github.com/wxt-dev/wxt/pull/308))
|
||||
- Use example tags to automate relevant example lists ([#311](https://github.com/wxt-dev/wxt/pull/311))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Update templates to `^0.13.0` ([#309](https://github.com/wxt-dev/wxt/pull/309))
|
||||
- Upgrade template dependencies ([#310](https://github.com/wxt-dev/wxt/pull/310))
|
||||
- Re-enable coverage ([#312](https://github.com/wxt-dev/wxt/pull/312))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- 冯不游
|
||||
|
||||
## v0.13.1
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.0...v0.13.1)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- **storage:** Support multiple `:` characters in storage keys ([#303](https://github.com/wxt-dev/wxt/pull/303))
|
||||
- Ship `vite/client` types internally for proper resolution using PNPM ([#304](https://github.com/wxt-dev/wxt/pull/304))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Reorder guide ([6421ab3](https://github.com/wxt-dev/wxt/commit/6421ab3))
|
||||
- General fixes and improvements ([2ad099b](https://github.com/wxt-dev/wxt/commit/2ad099b))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Update `scripts/build.ts` show current build step in progress, not completed count ([#306](https://github.com/wxt-dev/wxt/pull/306))
|
||||
|
||||
## v0.13.0
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.5...v0.13.0)
|
||||
|
||||
+9
-1
@@ -57,9 +57,17 @@ pnpm test
|
||||
pnpm docs:dev
|
||||
```
|
||||
|
||||
## Updating Docs
|
||||
|
||||
Documentation is written with VitePress, and is located in the `docs/` directory.
|
||||
|
||||
The API reference is generated from JSDoc comments in the source code. If there's a typo or change you want to make in there, you'll need to update the source code instead of a file in the `docs/` directory.
|
||||
|
||||
## Testing
|
||||
|
||||
WXT has unit and E2E tests. When making a change or adding a feature, make sure to update the tests or add new ones.
|
||||
WXT has unit and E2E tests. When making a change or adding a feature, make sure to update the tests or add new ones, if they exist.
|
||||
|
||||
> If they don't exist, feel free to create them, but that's a lot for a one-time contributor. A maintainer might add them to your PR though.
|
||||
|
||||
To run tests for a specific file, add the filename at the end of the test command:
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -4,10 +4,10 @@ export default defineBackground(() => {
|
||||
console.log(browser.runtime.id);
|
||||
logId();
|
||||
console.log({
|
||||
browser: __BROWSER__,
|
||||
chrome: __IS_CHROME__,
|
||||
firefox: __IS_FIREFOX__,
|
||||
manifestVersion: __MANIFEST_VERSION__,
|
||||
browser: import.meta.env.BROWSER,
|
||||
chrome: import.meta.env.CHROME,
|
||||
firefox: import.meta.env.FIREFOX,
|
||||
manifestVersion: import.meta.env.MANIFEST_VERSION,
|
||||
messages,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,10 @@ export default defineBackground(() => {
|
||||
browser.runtime.getURL('/');
|
||||
browser.runtime.getURL('/background.js');
|
||||
browser.runtime.getURL('/icon/128.png');
|
||||
browser.runtime.getURL('/example.html#hash');
|
||||
browser.runtime.getURL('/example.html?query=param');
|
||||
// @ts-expect-error: should only allow hashes/query params on HTML files
|
||||
browser.runtime.getURL('/icon-128.png?query=param');
|
||||
|
||||
// @ts-expect-error: should only accept known message names
|
||||
browser.i18n.getMessage('test');
|
||||
|
||||
@@ -2,9 +2,9 @@ export default defineContentScript({
|
||||
matches: ['*://*.google.com/*'],
|
||||
|
||||
main(ctx) {
|
||||
const ui = createContentScriptIframe(ctx, {
|
||||
const ui = createIframeUi(ctx, {
|
||||
page: '/iframe-src.html',
|
||||
type: 'overlay',
|
||||
position: 'overlay',
|
||||
anchor: 'form[action="/search"]',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
@@ -6,12 +6,12 @@ export default defineContentScript({
|
||||
cssInjectionMode: 'ui',
|
||||
|
||||
async main(ctx) {
|
||||
const ui = await createContentScriptUi(ctx, {
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'demo-ui',
|
||||
type: 'inline',
|
||||
position: 'inline',
|
||||
append: 'before',
|
||||
anchor: 'form[role=search]',
|
||||
mount: (container) => {
|
||||
onMount: (container) => {
|
||||
const app = document.createElement('div');
|
||||
app.textContent = 'Custom content script UI';
|
||||
container.append(app);
|
||||
|
||||
@@ -0,0 +1,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,6 +5,10 @@ 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
|
||||
@@ -12,6 +16,7 @@ const chromeExtensionIds = [
|
||||
'elfaihghhjjoknimpccccmkioofjjfkf', // StayFree - Website Blocker & Web Analytics
|
||||
'okifoaikfmpfcamplcfjkpdnhfodpkil', // Doozy: Ai Made Easy
|
||||
'lknmjhcajhfbbglglccadlfdjbaiifig', // tl;dv - Record, Transcribe & ChatGPT for Google Meet
|
||||
'youtube中文配音/oglffgiaiekgeicdgkdlnlkhliajdlja', // Youtube中文配音
|
||||
];
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import DefaultTheme from 'vitepress/theme';
|
||||
import Icon from '../components/Icon.vue';
|
||||
import EntrypointPatterns from '../components/EntrypointPatterns.vue';
|
||||
import UsingWxtSection from '../components/UsingWxtSection.vue';
|
||||
import ExampleList from '../components/ExampleList.vue';
|
||||
import './custom.css';
|
||||
|
||||
export default {
|
||||
@@ -10,5 +11,6 @@ export default {
|
||||
ctx.app.component('Icon', Icon);
|
||||
ctx.app.component('EntrypointPatterns', EntrypointPatterns);
|
||||
ctx.app.component('UsingWxtSection', UsingWxtSection);
|
||||
ctx.app.component('ExampleList', ExampleList);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ When creating content script entrypoints, they are automatically included in the
|
||||
```ts
|
||||
export default defineContentScript({
|
||||
// Set manifest options
|
||||
matches: ['*://google.com/*', '*://duckduckgo.com/*'],
|
||||
matches: string[],
|
||||
excludeMatches: undefined | [],
|
||||
includeGlobs: undefined | [],
|
||||
excludeGlobs: undefined | [],
|
||||
@@ -90,7 +90,7 @@ To include CSS with your content script, import the CSS file at the top of your
|
||||
|
||||
```
|
||||
|
||||
<srcDir>
|
||||
<srcDir>/
|
||||
└─ entrypoints/
|
||||
└─ overlay.content/
|
||||
├─ index.ts
|
||||
@@ -137,3 +137,5 @@ export default defineContentScript({
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
See [Content Script UI](/guide/content-script-ui) for more info on creating UIs and including CSS in content scripts.
|
||||
|
||||
@@ -13,15 +13,12 @@
|
||||
|
||||
## Definition
|
||||
|
||||
Plain old HTML file.
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Title</title>
|
||||
<!-- Set include/exclude if the page should be removed from some builds -->
|
||||
<meta name="manifest.include" content="['chrome', ...]" />
|
||||
<meta name="manifest.exclude" content="['chrome', ...]" />
|
||||
@@ -31,3 +28,13 @@ Plain old HTML file.
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Adding UI Elements
|
||||
|
||||
Chrome extensions allow you to add panels and side panes to the devtools window.
|
||||
|
||||

|
||||
|
||||
See the WXT's examples for a full walkthrough of extending the devtools window:
|
||||
|
||||
<ExampleList tag="devtools" />
|
||||
|
||||
@@ -10,10 +10,10 @@ Firefox does not support sandboxed pages.
|
||||
|
||||
<EntrypointPatterns
|
||||
:patterns="[
|
||||
['entrypoints/sandbox.html', 'sandbox.html'],
|
||||
['entrypoints/sandbox/index.html', 'sandbox.html'],
|
||||
['entrypoints/<name>.sandbox.html', '<name>.html` '],
|
||||
['entrypoints/<name>.sandbox/index.html', '<name>.html` '],
|
||||
['sandbox.html', 'sandbox.html'],
|
||||
['sandbox/index.html', 'sandbox.html'],
|
||||
['<name>.sandbox.html', '<name>.html` '],
|
||||
['<name>.sandbox/index.html', '<name>.html` '],
|
||||
]"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
# Unlisted Pages
|
||||
|
||||
HTML pages that are built by Vite, but are not included in the manifest.
|
||||
HTML pages that are bundled and shipped with the extension, but are not included in the manifest.
|
||||
|
||||
### Examples
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Unlisted Scripts
|
||||
|
||||
TypeScript files that are built, but are not included in the manifest.
|
||||
TypeScript files that are bundled and shipped with the extension, but are not included in the manifest.
|
||||
|
||||
You are responsible for loading/running these scripts where needed.
|
||||
|
||||
|
||||
+1
-21
@@ -2,26 +2,6 @@
|
||||
|
||||
Simple walkthroughs to accomplish common tasks or patterns with WXT.
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
const examples = ref()
|
||||
onMounted(async () => {
|
||||
const res = await fetch("https://raw.githubusercontent.com/wxt-dev/wxt-examples/main/examples.json");
|
||||
examples.value = await res.json();
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
<li v-if="examples == null">
|
||||
Loading...
|
||||
</li>
|
||||
<template v-else>
|
||||
<li v-for="example of examples">
|
||||
<a :href="example.url" target="_blank">{{ example.name }}</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<ExampleList />
|
||||
|
||||
> Full code available at [`wxt-dev/wxt-examples`](https://github.com/wxt-dev/wxt-examples)
|
||||
|
||||
@@ -14,7 +14,9 @@ Some WXT APIs can be used without importing them:
|
||||
- [`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`
|
||||
- [`createIntegratedUi`](/api/wxt/client/functions/createIntegratedUi) from `wxt/client`
|
||||
- [`createShadowRootUi`](/api/wxt/client/functions/createShadowRootUi) from `wxt/client`
|
||||
- [`createIframeUi`](/api/wxt/client/functions/createIframeUi) from `wxt/client`
|
||||
- [`fakeBrowser`](/api/wxt/testing/variables/fakeBrowser) from `wxt/testing`
|
||||
|
||||
And more!
|
||||
|
||||
+229
-140
@@ -6,35 +6,37 @@ There are three ways to mount a UI inside a content script:
|
||||
|
||||
Each has their own set of advantages and disadvantages.
|
||||
|
||||
| Method | Isolated Styles | HMR | Use page's context |
|
||||
| ---------- | :-------------: | :-: | :----------------: |
|
||||
| Integrated | ❌ | ❌ | ✅ |
|
||||
| ShadowRoot | ✅ | ❌ | ✅ |
|
||||
| IFrame | ✅ | ✅ | ❌ |
|
||||
| Method | Isolated Styles | Isolated Events | HMR | Use page's context |
|
||||
| ----------- | :-------------: | :-----------------: | :-: | :----------------: |
|
||||
| Integrated | ❌ | ❌ | ❌ | ✅ |
|
||||
| Shadow Root | ✅ | ✅ (off by default) | ❌ | ✅ |
|
||||
| IFrame | ✅ | ✅ | ✅ | ❌ |
|
||||
|
||||
## Integrated
|
||||
|
||||
Integrated content script UIs are injected alongside the content of a page. This means that they are affected by CSS on that page.
|
||||
|
||||
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({
|
||||
matches: ['<all_urls>'],
|
||||
|
||||
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();
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#anchor',
|
||||
onMount: (container) => {
|
||||
// Append children to the container
|
||||
const app = document.createElement('p');
|
||||
app.textContent = '...';
|
||||
container.append(app);
|
||||
},
|
||||
});
|
||||
|
||||
// Call mount to add the UI to the DOM
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -42,25 +44,29 @@ export default defineContentScript({
|
||||
```ts [Vue]
|
||||
// entrypoints/example-ui.content/index.ts
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
|
||||
main(ctx) {
|
||||
// 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();
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#anchor',
|
||||
onMount: (container) => {
|
||||
// Create the app and mount it to the UI container
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
return app;
|
||||
},
|
||||
onRemove: (app) => {
|
||||
// Unmount the app when the UI is removed
|
||||
app.unmount();
|
||||
},
|
||||
});
|
||||
|
||||
// Call mount to add the UI to the DOM
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -68,52 +74,59 @@ export default defineContentScript({
|
||||
```tsx [React]
|
||||
// entrypoints/example-ui.content/index.tsx
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
|
||||
main(ctx) {
|
||||
// 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();
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#anchor',
|
||||
onMount: (container) => {
|
||||
// Create a root on the UI container and render a component
|
||||
const root = ReactDOM.createRoot(container);
|
||||
root.render(<App />);
|
||||
return root;
|
||||
},
|
||||
onRemove: (root) => {
|
||||
// Unmount the root when the UI is removed
|
||||
root.unmount();
|
||||
},
|
||||
});
|
||||
|
||||
// Call mount to add the UI to the DOM
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts [Svelete]
|
||||
```ts [Svelte]
|
||||
// entrypoints/example-ui.content/index.ts
|
||||
import App from './App.svelte';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
|
||||
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,
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#anchor',
|
||||
onMount: (container) => {
|
||||
// Create the Svelte app inside the UI container
|
||||
const app = new App({
|
||||
target: container,
|
||||
});
|
||||
return app;
|
||||
},
|
||||
onRemove: (app) => {
|
||||
// Destroy the app when the UI is removed
|
||||
app.$destroy();
|
||||
},
|
||||
});
|
||||
|
||||
// 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();
|
||||
});
|
||||
// Call mount to add the UI to the DOM
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -123,56 +136,65 @@ export default defineContentScript({
|
||||
import { render } from 'solid-js/web';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
|
||||
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();
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#anchor',
|
||||
onMount: (container) => {
|
||||
// Render your app to the UI container
|
||||
const unmount = render(() => <div>...</div>, container);
|
||||
},
|
||||
onRemove: (unmount) => {
|
||||
// Unmount the app when the UI is removed
|
||||
unmount();
|
||||
},
|
||||
});
|
||||
|
||||
// Call mount to add the UI to the DOM
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
## ShadowRoot
|
||||
See the [API Reference](/api/wxt/client/functions/createIntegratedUi) for the complete list of options.
|
||||
|
||||
You can control how CSS is injected for an integrated content script UI with the [`cssInjectionMode`](/api/wxt/interfaces/ContentScriptBaseDefinition#cssinjectionmode) property. Usually, you'll want to leave it as `"manifest"`, the default, so the UI inherits its style from the website's CSS.
|
||||
|
||||
## Shadow Root
|
||||
|
||||
Often in web extensions, you don't want your content script's CSS affecting the page, or vise-versa. The [`ShadowRoot`](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot) API is ideal for this.
|
||||
|
||||
WXT 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.
|
||||
WXT's [`createShadowRootUi`](/api/wxt/client/functions/createShadowRootUi) abstracts all the `ShadowRoot` setup away, making it easy to create UIs with isolated CSS. It also supports an optional `isolateEvents` parameter to further isolate user interactions.
|
||||
|
||||
To use `createContentScriptUi`, follow these steps:
|
||||
To use `createShadowRootUi`, follow these steps:
|
||||
|
||||
1. Import your CSS file at the top of your content script
|
||||
2. Set [`cssInjectionMode: "ui"`](/api/wxt/interfaces/ContentScriptBaseDefinition#cssinjectionmode) inside `defineContentScript`
|
||||
3. Define your UI with `createContentScriptUi()`
|
||||
3. Define your UI with `createShadowRootUi()`
|
||||
4. Mount the UI so it is visible to users
|
||||
|
||||
```ts
|
||||
:::code-group
|
||||
|
||||
```ts [Vanilla]
|
||||
// 1. Import the style
|
||||
import './style.css';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
// 2. Set cssInjectionMode
|
||||
cssInjectionMode: 'ui',
|
||||
|
||||
async main(ctx) {
|
||||
// 3. Define your UI
|
||||
const ui = await createContentScriptUi(ctx, {
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'example-ui',
|
||||
anchor: '#anchor',
|
||||
type: 'inline',
|
||||
mount(container) {
|
||||
position: 'inline',
|
||||
onMount(container) {
|
||||
// Define how your UI will be mounted inside the container
|
||||
const app = document.createElement('p');
|
||||
app.textContent = 'Hello world!';
|
||||
@@ -186,91 +208,156 @@ export default defineContentScript({
|
||||
});
|
||||
```
|
||||
|
||||
> `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]
|
||||
// 1. Import the style
|
||||
import './style.css';
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
|
||||
const ui = createContentScriptUi(ctx, {
|
||||
// ...
|
||||
mount(container) {
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
return app;
|
||||
},
|
||||
onRemove(app) {
|
||||
app.unmount();
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
// 2. Set cssInjectionMode
|
||||
cssInjectionMode: 'ui',
|
||||
|
||||
async main(ctx) {
|
||||
// 3. Define your UI
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'example-ui',
|
||||
anchor: '#anchor',
|
||||
position: 'inline',
|
||||
onMount: (container) => {
|
||||
// Define how your UI will be mounted inside the container
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
return app;
|
||||
},
|
||||
onRemove: (app) => {
|
||||
// Unmount the app when the UI is removed
|
||||
app.unmount();
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Mount the UI
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```tsx [React]
|
||||
// 1. Import the style
|
||||
import './style.css';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
|
||||
const ui = createContentScriptUi(ctx, {
|
||||
// ...
|
||||
mount(container) {
|
||||
const root = ReactDOM.createRoot(container);
|
||||
root.render(...);
|
||||
return root;
|
||||
},
|
||||
onRemove(root) {
|
||||
root.unmount();
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
// 2. Set cssInjectionMode
|
||||
cssInjectionMode: 'ui',
|
||||
|
||||
async main(ctx) {
|
||||
// 3. Define your UI
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'example-ui',
|
||||
position: 'inline',
|
||||
anchor: '#anchor',
|
||||
onMount: (container) => {
|
||||
// Create a root on the UI container and render a component
|
||||
const root = ReactDOM.createRoot(container);
|
||||
root.render(<App />);
|
||||
return root;
|
||||
},
|
||||
onRemove: (root) => {
|
||||
// Unmount the root when the UI is removed
|
||||
root.unmount();
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Mount the UI
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts [Svelte]
|
||||
// 1. Import the style
|
||||
import './style.css';
|
||||
import App from './App.svelte';
|
||||
|
||||
const ui = createContentScriptUi(ctx, {
|
||||
// ...
|
||||
mount(container) {
|
||||
return new App({ target: container });
|
||||
},
|
||||
onRemove(app) {
|
||||
app.$destry();
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
// 2. Set cssInjectionMode
|
||||
cssInjectionMode: 'ui',
|
||||
|
||||
async main(ctx) {
|
||||
// 3. Define your UI
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'example-ui',
|
||||
position: 'inline',
|
||||
anchor: '#anchor',
|
||||
onMount: (container) => {
|
||||
// Create the Svelte app inside the UI container
|
||||
const app = new App({
|
||||
target: container,
|
||||
});
|
||||
return app;
|
||||
},
|
||||
onRemove: (app) => {
|
||||
// Destroy the app when the UI is removed
|
||||
app.$destroy();
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Mount the UI
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```tsx [Solid]
|
||||
// 1. Import the style
|
||||
import './style.css';
|
||||
import { render } from 'solid-js/web';
|
||||
|
||||
const ui = createContentScriptUi(ctx, {
|
||||
// ...
|
||||
mount(container) {
|
||||
return render(() => ..., container);
|
||||
},
|
||||
onRemove(unmount) {
|
||||
unmount();
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
// 2. Set cssInjectionMode
|
||||
cssInjectionMode: 'ui',
|
||||
|
||||
async main(ctx) {
|
||||
// 3. Define your UI
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'example-ui',
|
||||
position: 'inline',
|
||||
anchor: '#anchor',
|
||||
onMount: (container) => {
|
||||
// Render your app to the UI container
|
||||
const unmount = render(() => <div>...</div>, container);
|
||||
},
|
||||
onRemove: (unmount) => {
|
||||
// Unmount the app when the UI is removed
|
||||
unmount();
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Mount the UI
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
:::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.
|
||||
See the [API Reference](/api/wxt/client/functions/createShadowRootUi) for the complete list of options.
|
||||
|
||||
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`.
|
||||
:::info TailwindCSS
|
||||
`createShadowRootUi` supports TailwindCSS out of the box! When importing the styles, just import the main CSS file containing the `@tailwind` directives, and everything will just work :+1:.
|
||||
:::
|
||||
|
||||
## IFrame
|
||||
|
||||
If you don't need to run your UI in the same frame as the content script, you can use an IFrame to host your UI instead. Since an IFrame just hosts an HTML page, **_HMR is supported_**.
|
||||
|
||||
WXT provides a helper function, [`createContentScriptIframe`](/api/wxt/client/functions/createContentScriptUi), which simplifies setting up the IFrame.
|
||||
WXT provides a helper function, [`createIframeUi`](/api/wxt/client/functions/createIframeUi), which simplifies setting up the IFrame.
|
||||
|
||||
1. Create an HTML page that will be loaded into your IFrame
|
||||
```html
|
||||
@@ -305,22 +392,24 @@ WXT provides a helper function, [`createContentScriptIframe`](/api/wxt/client/fu
|
||||
|
||||
```ts
|
||||
export default defineContentScript({
|
||||
// ...
|
||||
matches: ['<all_urls>'],
|
||||
|
||||
async main(ctx) {
|
||||
// Define the UI
|
||||
const ui = await createContentScriptIframe(ctx, {
|
||||
const ui = await createIframeUi(ctx, {
|
||||
page: '/example-iframe.html',
|
||||
anchor: '#anchor',
|
||||
type: 'inline',
|
||||
position: 'inline',
|
||||
onMount: (wrapper, iframe) => {
|
||||
// Add styles to the iframe like width
|
||||
iframe.width = 123;
|
||||
},
|
||||
});
|
||||
|
||||
// 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.
|
||||
See the [API Reference](/api/wxt/client/functions/createIframeUi) for the complete list of options.
|
||||
|
||||
@@ -39,6 +39,8 @@ export default defineRunnerConfig({
|
||||
|
||||
:::
|
||||
|
||||
### Browser Binaries
|
||||
|
||||
`web-ext`'s browser discovery is very limitted. By default, it only guesses at where Chrome and Firefox are installed. If you've customized your install locations, you may need to tell `web-ext` where the binaries/executables are located using the [`binaries` option](/api/wxt/interfaces/ExtensionRunnerConfig#binaries). For other Chromium based browsers, like Edge or Opera, you'll need to explicitly list them in the `binaries` option as well, otherwise they will open in Chrome by default.
|
||||
|
||||
```ts
|
||||
@@ -57,3 +59,30 @@ export default defineRunnerConfig({
|
||||
:::tip
|
||||
When configuring browser binaries, it's helpful to put them in `~/web-ext.config.ts` instead of the project directory's `web-ext.config.ts` file. When placed in your home directory (`~/`), this config will be used by all WXT projects, so you only need to configure the binaries once.
|
||||
:::
|
||||
|
||||
### Other options
|
||||
|
||||
You can customize other options as well, like startup URLs, profiles, or additional command line arguments:
|
||||
|
||||
```ts
|
||||
// web-ext.config.ts
|
||||
import { defineRunnerConfig } from 'wxt';
|
||||
|
||||
export default defineRunnerConfig({
|
||||
startUrls: ['https://google.com', 'https://duckduckgo.com'],
|
||||
chromiumProfile: '/path/to/profile/to/use',
|
||||
chromiumArgs: ['--window-size=400x300'],
|
||||
});
|
||||
```
|
||||
|
||||
For a full list of options, see the [API Reference](/api/wxt/interfaces/ExtensionRunnerConfig).
|
||||
|
||||
## Reload the Extension
|
||||
|
||||
Normally, to manually reload an extension, you have to visit `chrome://extensions` and click the reload button for your extension.
|
||||
|
||||
When running `wxt` command to start the dev server, WXT adds a keyboard shortcut, `ctrl+E` for Windows/Linux and `cmd+E` for Mac, that reloads the extension when pressed, without visiting `chrome://extensions`.
|
||||
|
||||
:::note
|
||||
This shortcut is only available during development, and is not be added to your extension when running `wxt build` or `wxt-zip`.
|
||||
:::
|
||||
|
||||
@@ -86,3 +86,56 @@ export default defineContentScript({
|
||||
:::info
|
||||
For a full list of entrypoints and each of their options, see the [`/entrypoints` folder](/entrypoints/background) documentation.
|
||||
:::
|
||||
|
||||
### Side Effects
|
||||
|
||||
You cannot use imported variables outside the `main` function is JS entrypoints. This includes options, as shown below:
|
||||
|
||||
```ts
|
||||
// entrypoints/content.ts
|
||||
import { GOOGLE_MATCHES } from '~/utils/match-patterns';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: GOOGLE_MATCHES,
|
||||
main() {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```
|
||||
$ wxt build
|
||||
wxt build
|
||||
|
||||
WXT 0.14.1
|
||||
ℹ Building chrome-mv3 for production with Vite 5.0.5
|
||||
✖ Command failed after 360 ms
|
||||
|
||||
[8:55:54 AM] ERROR entrypoints/content.ts: Cannot use imported variable "GOOGLE_MATCHES" before main function. See https://wxt.dev/guide/entrypoints.html#side-effects
|
||||
```
|
||||
|
||||
This throws an error because WXT needs to import each entrypoint during the build process to extract its definition (containing the `match`, `run_at`, `include`/`exclude`, etc.) to render the `manifest.json` correctly. Before loading an entrypoint, a transformation is applied to remove all imports. This prevents imported modules (local or NPM) with side-effects from running during the build process, potentially throwing an error.
|
||||
|
||||
:::details Why?
|
||||
|
||||
When importing your entrypoint to get its definition, the file is imported in a **_node environement_**, and doesn't have access to the `window`, `chrome`, or `browser` globals a web extension ususally has access to. If WXT doesn't remove all the imports from the file, the imported modules could try and access one of these variables, throwing an error.
|
||||
|
||||
:::
|
||||
|
||||
:::warning
|
||||
See [`wxt-dev/wxt#336`](https://github.com/wxt-dev/wxt/issues/336) to track the status of this bug.
|
||||
:::
|
||||
|
||||
Usually, this error occurs when you try to extract options into a shared file or try to run code outside the `main` function. To fix the example from above, use litteral values when defining an entrypoint instead of importing them:
|
||||
|
||||
```ts
|
||||
import { GOOGLE_MATCHES } from '~/utils/match-patterns'; // [!code --]
|
||||
|
||||
export default defineContentScript({
|
||||
matches: GOOGLE_MATCHES, // [!code --]
|
||||
matches: ['*//*.google.com/*'], // [!code ++]
|
||||
main() {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -26,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
|
||||
@@ -122,5 +124,4 @@ export default defineConfig({
|
||||
|
||||
See the official localization examples for more details:
|
||||
|
||||
- [I18n](https://github.com/wxt-dev/wxt-examples/tree/main/examples/vanilla-i18n#readme)
|
||||
- [Vue I18n](https://github.com/wxt-dev/wxt-examples/tree/main/examples/vue-i18n#readme)
|
||||
<ExampleList tag="i18n" />
|
||||
|
||||
@@ -17,11 +17,9 @@ Separate build targets are written to their own output directories:
|
||||
|
||||
To build for a specific browser, pass the `-b --browser` flag from the CLI:
|
||||
|
||||
```
|
||||
|
||||
```sh
|
||||
wxt --browser firefox
|
||||
wxt build --browser firefox
|
||||
|
||||
```
|
||||
|
||||
By default, it will build for `chrome`. When excluding the [manifest version flags](#target-manifest-version), it will default to the commonly accepted manifest version for that browser.
|
||||
@@ -112,3 +110,24 @@ export default defineContentScript({
|
||||
:::warning
|
||||
Only `defineBackground` and `defineContentScript` support per-browser options right now.
|
||||
:::
|
||||
|
||||
## Runtime
|
||||
|
||||
To determine the browser or manifest version at runtime, you can use any of the below variables:
|
||||
|
||||
- `import.meta.env.BROWSER`: A string, the target browser, usually equal to the `--browser` flag
|
||||
- `import.meta.env.MANIFEST_VERSION`: A number, either `2` or `3`, depending on the manifest version targetted
|
||||
- `import.meta.env.CHROME`: A boolean equivalent to `import.meta.env.BROWSER === "chrome"`
|
||||
- `import.meta.env.FIREFOX`: A boolean equivalent to `import.meta.env.BROWSER === "firefox"`
|
||||
- `import.meta.env.EDGE`: A boolean equivalent to `import.meta.env.BROWSER === "edge"`
|
||||
- `import.meta.env.SAFARI`: A boolean equivalent to `import.meta.env.BROWSER === "safari"`
|
||||
- `import.meta.env.OPERA`: A boolean equivalent to `import.meta.env.BROWSER === "opera"`
|
||||
- `import.meta.env.COMMAND`: A string, `"serve"` when running `wxt` for development or `"build"` in all other cases.
|
||||
|
||||
:::info
|
||||
These variables are constants defined at build time based on the build target. They do not actually detect which browser the code is running in.
|
||||
|
||||
For example, if you build for `--browser chrome` and publish it on Edge, `import.meta.env.BROWSER` will be `"chrome"`, not `"edge"`. You have to build a separate ZIP for `--browser edge` before `import.meta.env.BROWSER` will be `"edge"`.
|
||||
|
||||
If you need to know the actual browser your code is being ran on, you should use a [user agent parser](https://www.npmjs.com/package/ua-parser-js).
|
||||
:::
|
||||
|
||||
@@ -6,9 +6,7 @@ WXT officially supports [Vitest](https://vitest.dev/) for unit tests and either
|
||||
|
||||
For details setting up each testing framework, see the official examples:
|
||||
|
||||
- [Vitest example](https://github.com/wxt-dev/wxt-examples/tree/main/examples/vanilla-vitest#readme)
|
||||
- [Playwright example](https://github.com/wxt-dev/wxt-examples/tree/main/examples/vanilla-playwright#readme)
|
||||
- [Puppeteer example](https://github.com/wxt-dev/wxt-examples/tree/main/examples/vanilla-puppeteer#readme)
|
||||
<ExampleList tag="testing" />
|
||||
|
||||
### Unofficial Frameworks
|
||||
|
||||
@@ -21,7 +19,7 @@ If you want to try to use a different framework for unit tests, you will need to
|
||||
- **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"`)
|
||||
- **Global Variables**: If you consume them, manually define globals provided by WXT (like `import.meta.env.BROWSER`) by adding them to the global scope before accessing them (`import.meta.env.BROWSER = "chrome"`)
|
||||
- **Import paths**: If you use the `@/` or `~/` path aliases, add them to your test environment
|
||||
|
||||
[Here's how Vitest is configured](https://github.com/wxt-dev/wxt/blob/main/src/testing/wxt-vitest-plugin.ts) for reference.
|
||||
|
||||
@@ -20,8 +20,9 @@ describe('Auto Imports', () => {
|
||||
const InvalidMatchPattern: typeof import('wxt/sandbox')['InvalidMatchPattern']
|
||||
const MatchPattern: typeof import('wxt/sandbox')['MatchPattern']
|
||||
const browser: typeof import('wxt/browser')['browser']
|
||||
const createContentScriptIframe: typeof import('wxt/client')['createContentScriptIframe']
|
||||
const createContentScriptUi: typeof import('wxt/client')['createContentScriptUi']
|
||||
const createIframeUi: typeof import('wxt/client')['createIframeUi']
|
||||
const createIntegratedUi: typeof import('wxt/client')['createIntegratedUi']
|
||||
const createShadowRootUi: typeof import('wxt/client')['createShadowRootUi']
|
||||
const defineBackground: typeof import('wxt/sandbox')['defineBackground']
|
||||
const defineConfig: typeof import('wxt')['defineConfig']
|
||||
const defineContentScript: typeof import('wxt/sandbox')['defineContentScript']
|
||||
@@ -44,7 +45,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" />
|
||||
@@ -83,7 +84,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" />
|
||||
|
||||
@@ -1,556 +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('entrypoints/unlisted.html');
|
||||
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('entrypoints/unlisted.html');
|
||||
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('entrypoints/unlisted.html');
|
||||
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('entrypoints/unlisted.html');
|
||||
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 },
|
||||
@@ -579,95 +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',
|
||||
});
|
||||
project.addFile('entrypoints/unlisted.html');
|
||||
|
||||
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',
|
||||
});
|
||||
project.addFile('entrypoints/unlisted.html');
|
||||
|
||||
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',
|
||||
});
|
||||
project.addFile('entrypoints/unlisted.html');
|
||||
|
||||
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.addFile('entrypoints/unlisted.html');
|
||||
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",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,16 +14,19 @@ describe('TypeScript Project', () => {
|
||||
----------------------------------------
|
||||
// Generated by wxt
|
||||
export {}
|
||||
declare global {
|
||||
const __MANIFEST_VERSION__: 2 | 3;
|
||||
const __BROWSER__: string;
|
||||
const __IS_CHROME__: boolean;
|
||||
const __IS_FIREFOX__: boolean;
|
||||
const __IS_SAFARI__: boolean;
|
||||
const __IS_EDGE__: boolean;
|
||||
const __IS_OPERA__: boolean;
|
||||
const __COMMAND__: "build" | "serve";
|
||||
const __ENTRYPOINT__: string;
|
||||
interface ImportMetaEnv {
|
||||
readonly MANIFEST_VERSION: 2 | 3;
|
||||
readonly BROWSER: string;
|
||||
readonly CHROME: boolean;
|
||||
readonly FIREFOX: boolean;
|
||||
readonly SAFARI: boolean;
|
||||
readonly EDGE: boolean;
|
||||
readonly OPERA: boolean;
|
||||
readonly COMMAND: "build" | "serve";
|
||||
readonly ENTRYPOINT: string;
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
"
|
||||
`);
|
||||
@@ -49,8 +52,10 @@ describe('TypeScript Project', () => {
|
||||
| "/options.html"
|
||||
| "/popup.html"
|
||||
| "/sandbox.html"
|
||||
type HtmlPublicPath = Extract<PublicPath, \`\${string}.html\`>
|
||||
export interface WxtRuntime extends Runtime.Static {
|
||||
getURL(path: PublicPath): string;
|
||||
getURL(path: \`\${HtmlPublicPath}\${string}\`): string;
|
||||
}
|
||||
}
|
||||
"
|
||||
@@ -131,7 +136,7 @@ describe('TypeScript Project', () => {
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
/**
|
||||
*
|
||||
* No message description.
|
||||
*
|
||||
* "<browser.i18n.getUiLocale()>"
|
||||
*/
|
||||
@@ -227,7 +232,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" />
|
||||
|
||||
+11
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wxt",
|
||||
"type": "module",
|
||||
"version": "0.13.0",
|
||||
"version": "0.15.0",
|
||||
"description": "Next gen framework for developing web extensions",
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
@@ -76,6 +76,9 @@
|
||||
"types": "./dist/storage.d.cts",
|
||||
"default": "./dist/storage.cjs"
|
||||
}
|
||||
},
|
||||
"./vite-builder-env": {
|
||||
"types": "./dist/vite-builder-env.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -90,7 +93,7 @@
|
||||
"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",
|
||||
@@ -102,7 +105,7 @@
|
||||
"dependencies": {
|
||||
"@types/webextension-polyfill": "^0.10.5",
|
||||
"@webext-core/fake-browser": "^1.3.1",
|
||||
"@webext-core/isolated-element": "^1.0.4",
|
||||
"@webext-core/isolated-element": "^1.1.1",
|
||||
"@webext-core/match-patterns": "^1.0.3",
|
||||
"async-mutex": "^0.4.0",
|
||||
"c12": "^1.5.1",
|
||||
@@ -122,7 +125,9 @@
|
||||
"jiti": "^1.21.0",
|
||||
"json5": "^2.2.3",
|
||||
"linkedom": "^0.16.1",
|
||||
"manage-path": "^2.0.0",
|
||||
"minimatch": "^9.0.3",
|
||||
"natural-compare": "^1.4.0",
|
||||
"normalize-path": "^3.0.0",
|
||||
"ora": "^7.0.1",
|
||||
"picocolors": "^1.0.0",
|
||||
@@ -130,7 +135,7 @@
|
||||
"rollup-plugin-visualizer": "^5.9.2",
|
||||
"unimport": "^3.4.0",
|
||||
"vite": "^5.0.0",
|
||||
"web-ext-run": "^0.1.0",
|
||||
"web-ext-run": "^0.1.2",
|
||||
"webextension-polyfill": "^0.10.0",
|
||||
"zip-dir": "^2.0.0"
|
||||
},
|
||||
@@ -138,6 +143,7 @@
|
||||
"@faker-js/faker": "^8.3.1",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/lodash.merge": "^4.6.9",
|
||||
"@types/natural-compare": "^1.4.3",
|
||||
"@types/node": "^20.10.3",
|
||||
"@types/normalize-path": "^3.0.2",
|
||||
"@types/prompts": "^2.4.9",
|
||||
@@ -157,7 +163,7 @@
|
||||
"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",
|
||||
"vitepress": "1.0.0-rc.34",
|
||||
"vitest": "^1.1.0",
|
||||
"vitest-mock-extended": "^1.3.1",
|
||||
"vue": "^3.3.10"
|
||||
|
||||
Generated
+334
-431
File diff suppressed because it is too large
Load Diff
+11
-3
@@ -29,9 +29,9 @@ const preset = {
|
||||
} 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;
|
||||
},
|
||||
@@ -97,6 +97,11 @@ const config: tsup.Options[] = [
|
||||
cli: 'src/cli.ts',
|
||||
},
|
||||
format: ['esm'],
|
||||
banner: {
|
||||
// Fixes dynamic require of nodejs modules. See https://github.com/wxt-dev/wxt/issues/355
|
||||
// https://github.com/evanw/esbuild/issues/1921#issuecomment-1152991694
|
||||
js: "import { createRequire } from 'module';const require = createRequire(import.meta.url);",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -106,6 +111,9 @@ await spinnerPMap(config).catch((err) => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Copy "public" files that need shipped inside WXT
|
||||
await fs.copyFile('src/vite-builder-env.d.ts', 'dist/vite-builder-env.d.ts');
|
||||
|
||||
spinner.clear().stop();
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { describe, it, vi, beforeEach, expect } from 'vitest';
|
||||
import { build } from '../core/build';
|
||||
import { createServer } from '../core/create-server';
|
||||
import { zip } from '../core/zip';
|
||||
import { prepare } from '../core/prepare';
|
||||
import { clean } from '../core/clean';
|
||||
import { initialize } from '../core/initialize';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import consola from 'consola';
|
||||
|
||||
vi.mock('../core/build');
|
||||
const buildMock = vi.mocked(build);
|
||||
|
||||
vi.mock('../core/create-server');
|
||||
const createServerMock = vi.mocked(createServer);
|
||||
|
||||
vi.mock('../core/zip');
|
||||
const zipMock = vi.mocked(zip);
|
||||
|
||||
vi.mock('../core/prepare');
|
||||
const prepareMock = vi.mocked(prepare);
|
||||
|
||||
vi.mock('../core/clean');
|
||||
const cleanMock = vi.mocked(clean);
|
||||
|
||||
vi.mock('../core/initialize');
|
||||
const initializeMock = vi.mocked(initialize);
|
||||
|
||||
consola.wrapConsole();
|
||||
|
||||
const ogArgv = process.argv;
|
||||
|
||||
function mockArgv(...args: string[]) {
|
||||
process.argv = ['/bin/node', 'bin/wxt.mjs', ...args];
|
||||
}
|
||||
|
||||
describe('CLI', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.argv = ogArgv;
|
||||
createServerMock.mockResolvedValue(mock());
|
||||
});
|
||||
|
||||
describe('dev', () => {
|
||||
it('should not pass any config when no flags are passed', async () => {
|
||||
mockArgv();
|
||||
await import('../cli');
|
||||
|
||||
expect(createServerMock).toBeCalledWith({});
|
||||
});
|
||||
|
||||
it('should respect passing a custom root', async () => {
|
||||
mockArgv('path/to/root');
|
||||
await import('../cli');
|
||||
|
||||
expect(createServerMock).toBeCalledWith({
|
||||
root: 'path/to/root',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect a custom config file', async () => {
|
||||
mockArgv('-c', './path/to/config.ts');
|
||||
await import('../cli');
|
||||
|
||||
expect(createServerMock).toBeCalledWith({
|
||||
configFile: './path/to/config.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing a custom mode', async () => {
|
||||
mockArgv('-m', 'development');
|
||||
await import('../cli');
|
||||
|
||||
expect(createServerMock).toBeCalledWith({
|
||||
mode: 'development',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing a custom browser', async () => {
|
||||
mockArgv('-b', 'firefox');
|
||||
await import('../cli');
|
||||
|
||||
expect(createServerMock).toBeCalledWith({
|
||||
browser: 'firefox',
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass correct filtered entrypoints', async () => {
|
||||
mockArgv('-e', 'popup', '-e', 'options');
|
||||
await import('../cli');
|
||||
|
||||
expect(createServerMock).toBeCalledWith({
|
||||
filterEntrypoints: ['popup', 'options'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --mv2', async () => {
|
||||
mockArgv('--mv2');
|
||||
await import('../cli');
|
||||
|
||||
expect(createServerMock).toBeCalledWith({
|
||||
manifestVersion: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --mv3', async () => {
|
||||
mockArgv('--mv3');
|
||||
await import('../cli');
|
||||
|
||||
expect(createServerMock).toBeCalledWith({
|
||||
manifestVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --debug', async () => {
|
||||
mockArgv('--debug');
|
||||
await import('../cli');
|
||||
|
||||
expect(createServerMock).toBeCalledWith({
|
||||
debug: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('build', () => {
|
||||
it('should not pass any config when no flags are passed', async () => {
|
||||
mockArgv('build');
|
||||
await import('../cli');
|
||||
|
||||
expect(buildMock).toBeCalledWith({});
|
||||
});
|
||||
|
||||
it('should respect passing a custom root', async () => {
|
||||
mockArgv('build', 'path/to/root');
|
||||
await import('../cli');
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
root: 'path/to/root',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect a custom config file', async () => {
|
||||
mockArgv('build', '-c', './path/to/config.ts');
|
||||
await import('../cli');
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
configFile: './path/to/config.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing a custom mode', async () => {
|
||||
mockArgv('build', '-m', 'development');
|
||||
await import('../cli');
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
mode: 'development',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing a custom browser', async () => {
|
||||
mockArgv('build', '-b', 'firefox');
|
||||
await import('../cli');
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
browser: 'firefox',
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass correct filtered entrypoints', async () => {
|
||||
mockArgv('build', '-e', 'popup', '-e', 'options');
|
||||
await import('../cli');
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
filterEntrypoints: ['popup', 'options'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --mv2', async () => {
|
||||
mockArgv('build', '--mv2');
|
||||
await import('../cli');
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
manifestVersion: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --mv3', async () => {
|
||||
mockArgv('build', '--mv3');
|
||||
await import('../cli');
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
manifestVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include analysis in the build', async () => {
|
||||
mockArgv('build', '--analyze');
|
||||
await import('../cli');
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
analysis: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --debug', async () => {
|
||||
mockArgv('build', '--debug');
|
||||
await import('../cli');
|
||||
|
||||
expect(buildMock).toBeCalledWith({
|
||||
debug: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('zip', () => {
|
||||
it('should not pass any config when no flags are passed', async () => {
|
||||
mockArgv('zip');
|
||||
await import('../cli');
|
||||
|
||||
expect(zipMock).toBeCalledWith({});
|
||||
});
|
||||
|
||||
it('should respect passing a custom root', async () => {
|
||||
mockArgv('zip', 'path/to/root');
|
||||
await import('../cli');
|
||||
|
||||
expect(zipMock).toBeCalledWith({
|
||||
root: 'path/to/root',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect a custom config file', async () => {
|
||||
mockArgv('zip', '-c', './path/to/config.ts');
|
||||
await import('../cli');
|
||||
|
||||
expect(zipMock).toBeCalledWith({
|
||||
configFile: './path/to/config.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing a custom mode', async () => {
|
||||
mockArgv('zip', '-m', 'development');
|
||||
await import('../cli');
|
||||
|
||||
expect(zipMock).toBeCalledWith({
|
||||
mode: 'development',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing a custom browser', async () => {
|
||||
mockArgv('zip', '-b', 'firefox');
|
||||
await import('../cli');
|
||||
|
||||
expect(zipMock).toBeCalledWith({
|
||||
browser: 'firefox',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --mv2', async () => {
|
||||
mockArgv('zip', '--mv2');
|
||||
await import('../cli');
|
||||
|
||||
expect(zipMock).toBeCalledWith({
|
||||
manifestVersion: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --mv3', async () => {
|
||||
mockArgv('zip', '--mv3');
|
||||
await import('../cli');
|
||||
|
||||
expect(zipMock).toBeCalledWith({
|
||||
manifestVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --debug', async () => {
|
||||
mockArgv('zip', '--debug');
|
||||
await import('../cli');
|
||||
|
||||
expect(zipMock).toBeCalledWith({
|
||||
debug: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepare', () => {
|
||||
it('should not pass any config when no flags are passed', async () => {
|
||||
mockArgv('prepare');
|
||||
await import('../cli');
|
||||
|
||||
expect(prepareMock).toBeCalledWith({});
|
||||
});
|
||||
|
||||
it('should respect passing a custom root', async () => {
|
||||
mockArgv('prepare', 'path/to/root');
|
||||
await import('../cli');
|
||||
|
||||
expect(prepareMock).toBeCalledWith({
|
||||
root: 'path/to/root',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect a custom config file', async () => {
|
||||
mockArgv('prepare', '-c', './path/to/config.ts');
|
||||
await import('../cli');
|
||||
|
||||
expect(prepareMock).toBeCalledWith({
|
||||
configFile: './path/to/config.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --debug', async () => {
|
||||
mockArgv('prepare', '--debug');
|
||||
await import('../cli');
|
||||
|
||||
expect(prepareMock).toBeCalledWith({
|
||||
debug: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clean', () => {
|
||||
it('should not pass any config when no flags are passed', async () => {
|
||||
mockArgv('clean');
|
||||
await import('../cli');
|
||||
|
||||
expect(cleanMock).toBeCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('should respect passing a custom root', async () => {
|
||||
mockArgv('clean', 'path/to/root');
|
||||
await import('../cli');
|
||||
|
||||
expect(cleanMock).toBeCalledWith('path/to/root');
|
||||
});
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should not pass any options when no flags are passed', async () => {
|
||||
mockArgv('init');
|
||||
await import('../cli');
|
||||
|
||||
expect(initializeMock).toBeCalledWith({});
|
||||
});
|
||||
|
||||
it('should respect the provided folder', async () => {
|
||||
mockArgv('init', 'path/to/folder');
|
||||
await import('../cli');
|
||||
|
||||
expect(initializeMock).toBeCalledWith({
|
||||
directory: 'path/to/folder',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --template', async () => {
|
||||
mockArgv('init', '-t', 'vue');
|
||||
await import('../cli');
|
||||
|
||||
expect(initializeMock).toBeCalledWith({
|
||||
template: 'vue',
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect passing --pm', async () => {
|
||||
mockArgv('init', '--pm', 'pnpm');
|
||||
await import('../cli');
|
||||
|
||||
expect(initializeMock).toBeCalledWith({
|
||||
packageManager: 'pnpm',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+34
-5
@@ -4,6 +4,7 @@ import { build, clean, createServer, initialize, prepare, zip } from '~/core';
|
||||
import consola, { LogLevels } from 'consola';
|
||||
import { printHeader } from '~/core/utils/log';
|
||||
import { formatDuration } from '~/core/utils/time';
|
||||
import { ValidationError } from './core/utils/validation';
|
||||
|
||||
// TODO: Remove. See https://github.com/wxt-dev/wxt/issues/277
|
||||
process.env.VITE_CJS_IGNORE_WARNING = 'true';
|
||||
@@ -20,6 +21,13 @@ cli
|
||||
.option('-c, --config <file>', 'use specified config file')
|
||||
.option('-m, --mode <mode>', 'set env mode')
|
||||
.option('-b, --browser <browser>', 'specify a browser')
|
||||
.option(
|
||||
'-e, --filter-entrypoint <entrypoint>',
|
||||
'only build specific entrypoints',
|
||||
{
|
||||
type: [],
|
||||
},
|
||||
)
|
||||
.option('--mv3', 'target manifest v3')
|
||||
.option('--mv2', 'target manifest v2')
|
||||
.action(
|
||||
@@ -31,6 +39,7 @@ cli
|
||||
manifestVersion: flags.mv3 ? 3 : flags.mv2 ? 2 : undefined,
|
||||
configFile: flags.config,
|
||||
debug: flags.debug,
|
||||
filterEntrypoints: getArrayFromFlags(flags, 'filterEntrypoint'),
|
||||
});
|
||||
await server.start();
|
||||
return { isOngoing: true };
|
||||
@@ -43,6 +52,13 @@ cli
|
||||
.option('-c, --config <file>', 'use specified config file')
|
||||
.option('-m, --mode <mode>', 'set env mode')
|
||||
.option('-b, --browser <browser>', 'specify a browser')
|
||||
.option(
|
||||
'-e, --filter-entrypoint <entrypoint>',
|
||||
'only build specific entrypoints',
|
||||
{
|
||||
type: [],
|
||||
},
|
||||
)
|
||||
.option('--mv3', 'target manifest v3')
|
||||
.option('--mv2', 'target manifest v2')
|
||||
.option('--analyze', 'visualize extension bundle')
|
||||
@@ -55,9 +71,8 @@ cli
|
||||
manifestVersion: flags.mv3 ? 3 : flags.mv2 ? 2 : undefined,
|
||||
configFile: flags.config,
|
||||
debug: flags.debug,
|
||||
analysis: {
|
||||
enabled: flags.analyze,
|
||||
},
|
||||
analysis: flags.analyze ? { enabled: true } : undefined,
|
||||
filterEntrypoints: getArrayFromFlags(flags, 'filterEntrypoint'),
|
||||
});
|
||||
}),
|
||||
);
|
||||
@@ -125,7 +140,7 @@ cli
|
||||
),
|
||||
);
|
||||
|
||||
cli.parse();
|
||||
cli.parse(process.argv);
|
||||
|
||||
/**
|
||||
* Wrap an action handler to add a timer, error handling, and maybe enable debug mode.
|
||||
@@ -160,8 +175,22 @@ function wrapAction(
|
||||
consola.fail(
|
||||
`Command failed after ${formatDuration(Date.now() - startTime)}`,
|
||||
);
|
||||
consola.error(err);
|
||||
if (err instanceof ValidationError) {
|
||||
// Don't log these errors, they've already been logged
|
||||
} else {
|
||||
consola.error(err);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Array flags, when not passed, are either `undefined` or `[undefined]`. This function filters out
|
||||
* the
|
||||
*/
|
||||
function getArrayFromFlags<T>(flags: any, name: string): T[] | undefined {
|
||||
const array = [flags[name]].flat() as Array<T | undefined>;
|
||||
const result = array.filter((item) => item != null) as T[];
|
||||
return result.length ? result : undefined;
|
||||
}
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createContentScriptIframe } from '~/client/content-scripts/content-script-iframe';
|
||||
import { ContentScriptContext } from '~/client/content-scripts/content-script-context';
|
||||
|
||||
const createCtx = () => new ContentScriptContext('test');
|
||||
|
||||
const fetch = vi.fn();
|
||||
|
||||
describe('createContentScriptIframe', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="parent">
|
||||
<p id="one">one</p>
|
||||
<p id="two">two</p>
|
||||
<p id="three"></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
window.fetch = fetch;
|
||||
fetch.mockResolvedValue({ text: () => Promise.resolve('') });
|
||||
});
|
||||
|
||||
describe('mount', () => {
|
||||
describe('append option', () => {
|
||||
it.each([undefined, 'last' as const])(
|
||||
'should append the element as the last child to the anchor when append=%s',
|
||||
async (append) => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor: '#parent',
|
||||
append,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(4)')?.tagName,
|
||||
).toEqual('DIV');
|
||||
},
|
||||
);
|
||||
|
||||
it('should append the element as the first child to the anchor when append=first', async () => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor: '#parent',
|
||||
append: 'first',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(1)')?.tagName,
|
||||
).toEqual('DIV');
|
||||
});
|
||||
|
||||
it('should append the element normally when append=first but there are no other children', async () => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor: '#three',
|
||||
append: 'first',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#three > :nth-child(1)')?.tagName,
|
||||
).toEqual('DIV');
|
||||
});
|
||||
|
||||
it('should replace the anchor with the element when append=replace', async () => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor: '#two',
|
||||
append: 'replace',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(2)')?.tagName,
|
||||
).toEqual('DIV');
|
||||
});
|
||||
|
||||
it('should append the element after the anchor when append=before', async () => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor: '#two',
|
||||
append: 'before',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(2)')?.tagName,
|
||||
).toEqual('DIV');
|
||||
});
|
||||
|
||||
it('should append the element after the anchor when append=after', async () => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor: '#two',
|
||||
append: 'after',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
// Happy DOM doesn't work in this case, so we just make sure the element is added.
|
||||
// expect(
|
||||
// document.querySelector('#parent > :nth-child(3)')?.tagName,
|
||||
// ).toEqual('DIV');
|
||||
expect(document.querySelector('DIV')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should apply a custom function', async () => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor: '#three',
|
||||
append: (anchor, ui) => anchor.replaceWith(ui),
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(3)')?.tagName,
|
||||
).toEqual('DIV');
|
||||
});
|
||||
});
|
||||
|
||||
it('should default the anchor to the body when unset', async () => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('body > :nth-child(2)')?.tagName).toEqual(
|
||||
'DIV',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['#four', () => document.querySelector('#four')])(
|
||||
"should throw an error if the anchor doesn't exist",
|
||||
async (anchor) => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor,
|
||||
});
|
||||
|
||||
expect(ui.mount).toThrow(
|
||||
'Failed to mount content script UI: could not find anchor element',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it("should not fail if the ui hasn't been mounted", async () => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
});
|
||||
ui.remove();
|
||||
});
|
||||
|
||||
it('should automatically remove the UI when the context is invalidated', async () => {
|
||||
const ctx = createCtx();
|
||||
const ui = await createContentScriptIframe(ctx, {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('.wxt-iframe-wrapper')).toBeDefined();
|
||||
|
||||
ctx.abort();
|
||||
expect(document.querySelector('.wxt-iframe-wrapper')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('type', () => {
|
||||
it.each(['inline', 'overlay', 'modal'] as const)(
|
||||
'should render type=%s',
|
||||
async (type) => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('DIV')).toBeDefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('anchor', () => {
|
||||
it.each([
|
||||
() => '#two',
|
||||
() => () => '#two',
|
||||
() => document.querySelector('#two'),
|
||||
() => () => document.querySelector('#two'),
|
||||
])('should render anchor=%s', async (getAnchor) => {
|
||||
const ui = await createContentScriptIframe(createCtx(), {
|
||||
page: '/test.html',
|
||||
type: 'inline',
|
||||
anchor: getAnchor(),
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('DIV')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,335 +0,0 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createContentScriptUi } from '~/client/content-scripts/content-script-ui';
|
||||
import { ContentScriptContext } from '~/client/content-scripts/content-script-context';
|
||||
import { createIsolatedElement } from '@webext-core/isolated-element';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { browser } from '~/browser';
|
||||
|
||||
vi.mock('@webext-core/isolated-element', async () => {
|
||||
const { vi } = await import('vitest');
|
||||
return {
|
||||
createIsolatedElement: vi.fn(),
|
||||
};
|
||||
});
|
||||
const createIsolatedElementMock = vi.mocked(createIsolatedElement);
|
||||
|
||||
const testApp = (container: Element) => {
|
||||
const app = document.createElement('div');
|
||||
app.textContent = 'App';
|
||||
container.append(app);
|
||||
};
|
||||
|
||||
const createCtx = () => new ContentScriptContext('test');
|
||||
|
||||
const fetch = vi.fn();
|
||||
|
||||
describe('createContentScriptUi', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="parent">
|
||||
<p id="one">one</p>
|
||||
<p id="two">two</p>
|
||||
<p id="three"></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
window.fetch = fetch;
|
||||
fetch.mockResolvedValue({ text: () => Promise.resolve('') });
|
||||
|
||||
createIsolatedElementMock.mockImplementation(async (config) => {
|
||||
const parentElement = document.createElement(config.name);
|
||||
const isolatedElement = document.createElement('html');
|
||||
parentElement.append(isolatedElement);
|
||||
|
||||
return {
|
||||
isolatedElement,
|
||||
parentElement: parentElement,
|
||||
shadow: mock<ShadowRoot>({
|
||||
querySelector: (selector: string) => {
|
||||
if (selector === 'html') return isolatedElement;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
describe('css', () => {
|
||||
it('should load the CSS for the current entrypoint when cssInjectionMode=ui', async () => {
|
||||
fetch.mockResolvedValue({ text: () => Promise.resolve('body {}') });
|
||||
const ctx = new ContentScriptContext('test', {
|
||||
matches: [],
|
||||
cssInjectionMode: 'ui',
|
||||
});
|
||||
|
||||
await createContentScriptUi(ctx, {
|
||||
name: 'test',
|
||||
type: 'inline',
|
||||
mount: testApp,
|
||||
});
|
||||
|
||||
expect(fetch).toBeCalledTimes(1);
|
||||
expect(fetch).toBeCalledWith(
|
||||
`chrome-extension://${browser.runtime.id}/content-scripts/${__ENTRYPOINT__}.css`,
|
||||
);
|
||||
expect(createIsolatedElementMock).toBeCalledTimes(1);
|
||||
expect(createIsolatedElementMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
css: {
|
||||
textContent: 'body {}',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should still load the UI when fetch fails to load CSS file when cssInjectionMode=ui', async () => {
|
||||
const error = Error('Test fetch error');
|
||||
fetch.mockRejectedValue(error);
|
||||
const ctx = new ContentScriptContext('test', {
|
||||
matches: [],
|
||||
cssInjectionMode: 'ui',
|
||||
});
|
||||
|
||||
await createContentScriptUi(ctx, {
|
||||
name: 'test',
|
||||
type: 'inline',
|
||||
mount: testApp,
|
||||
});
|
||||
|
||||
expect(fetch).toBeCalledTimes(1);
|
||||
expect(fetch).toBeCalledWith(
|
||||
`chrome-extension://${browser.runtime.id}/content-scripts/${__ENTRYPOINT__}.css`,
|
||||
);
|
||||
expect(createIsolatedElementMock).toBeCalledTimes(1);
|
||||
expect(createIsolatedElementMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
css: {
|
||||
textContent: '',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['manifest', 'manual'] as const)(
|
||||
'should not fetch CSS when cssInjectionMode=%s',
|
||||
async (cssInjectionMode) => {
|
||||
const ctx = new ContentScriptContext('test', {
|
||||
matches: [],
|
||||
cssInjectionMode,
|
||||
});
|
||||
|
||||
await createContentScriptUi(ctx, {
|
||||
name: 'test',
|
||||
type: 'inline',
|
||||
mount: testApp,
|
||||
});
|
||||
|
||||
expect(fetch).not.toBeCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('mount', () => {
|
||||
describe('append option', () => {
|
||||
it.each([undefined, 'last' as const])(
|
||||
'should append the element as the last child to the anchor when append=%s',
|
||||
async (append) => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
anchor: '#parent',
|
||||
append,
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(4)')?.tagName,
|
||||
).toEqual('TEST-APP');
|
||||
},
|
||||
);
|
||||
|
||||
it('should append the element as the first child to the anchor when append=first', async () => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
anchor: '#parent',
|
||||
append: 'first',
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(1)')?.tagName,
|
||||
).toEqual('TEST-APP');
|
||||
});
|
||||
|
||||
it('should append the element normally when append=first but there are no other children', async () => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
anchor: '#three',
|
||||
append: 'first',
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#three > :nth-child(1)')?.tagName,
|
||||
).toEqual('TEST-APP');
|
||||
});
|
||||
|
||||
it('should replace the anchor with the element when append=replace', async () => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
anchor: '#two',
|
||||
append: 'replace',
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(2)')?.tagName,
|
||||
).toEqual('TEST-APP');
|
||||
});
|
||||
|
||||
it('should append the element after the anchor when append=before', async () => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
anchor: '#two',
|
||||
append: 'before',
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(2)')?.tagName,
|
||||
).toEqual('TEST-APP');
|
||||
});
|
||||
|
||||
it('should append the element after the anchor when append=after', async () => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
anchor: '#two',
|
||||
append: 'after',
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
// Happy DOM doesn't work in this case, so we just make sure the element is added.
|
||||
// expect(
|
||||
// document.querySelector('#parent > :nth-child(3)')?.tagName,
|
||||
// ).toEqual('TEST-APP');
|
||||
expect(document.querySelector('test-app')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should apply a custom function', async () => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
anchor: '#three',
|
||||
append: (anchor, ui) => anchor.replaceWith(ui),
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(3)')?.tagName,
|
||||
).toEqual('TEST-APP');
|
||||
});
|
||||
});
|
||||
|
||||
it('should default the anchor to the body when unset', async () => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('body > :nth-child(2)')?.tagName).toEqual(
|
||||
'TEST-APP',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['#four', () => document.querySelector('#four')])(
|
||||
"should throw an error if the anchor doesn't exist",
|
||||
async (anchor) => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test',
|
||||
type: 'inline',
|
||||
anchor,
|
||||
mount: testApp,
|
||||
});
|
||||
|
||||
expect(ui.mount).toThrow(
|
||||
'Failed to mount content script UI: could not find anchor element',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it("should not fail if the ui hasn't been mounted", async () => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
mount: testApp,
|
||||
});
|
||||
ui.remove();
|
||||
});
|
||||
|
||||
it('should automatically remove the UI when the context is invalidated', async () => {
|
||||
const ctx = createCtx();
|
||||
const ui = await createContentScriptUi(ctx, {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('test-app')).toBeDefined();
|
||||
|
||||
ctx.abort();
|
||||
expect(document.querySelector('test-app')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('type', () => {
|
||||
it.each(['inline', 'overlay', 'modal'] as const)(
|
||||
'should render type=%s',
|
||||
async (type) => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type,
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('test-app')).toBeDefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('anchor', () => {
|
||||
it.each([
|
||||
() => '#two',
|
||||
() => () => '#two',
|
||||
() => document.querySelector('#two'),
|
||||
() => () => document.querySelector('#two'),
|
||||
])('should render anchor=%s', async (getAnchor) => {
|
||||
const ui = await createContentScriptUi(createCtx(), {
|
||||
name: 'test-app',
|
||||
type: 'inline',
|
||||
anchor: getAnchor(),
|
||||
mount: testApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('test-app')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { browser } from '~/browser';
|
||||
import {
|
||||
ContentScriptAnchoredOptions,
|
||||
ContentScriptPositioningOptions,
|
||||
applyContentScriptUiPosition,
|
||||
mountContentScriptUiRoot,
|
||||
} from '../utils/content-script-ui';
|
||||
import { ContentScriptContext } from './content-script-context';
|
||||
|
||||
/**
|
||||
* Utility for mounting a content script UI inside an iframe. Automatically removed from the DOM
|
||||
* when the content script's context is invalidated.
|
||||
*
|
||||
* See https://wxt.dev/entrypoints/content-scripts.html#iframe for full documentation.
|
||||
*
|
||||
* @example
|
||||
* export default defineContentScript({
|
||||
* matches: ["*://*.google.com/*"],
|
||||
*
|
||||
* main(ctx) {
|
||||
* const ui = await createContentScriptIframe(ctx, {
|
||||
* page: "/content-script-overlay.html",
|
||||
* type: "modal",
|
||||
* })
|
||||
* ui.mount();
|
||||
* }
|
||||
* })
|
||||
*/
|
||||
export function createContentScriptIframe(
|
||||
ctx: ContentScriptContext,
|
||||
options: ContentScriptIframeOptions,
|
||||
): ContentScriptIframe {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.classList.add('wxt-iframe-wrapper');
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = browser.runtime.getURL(options.page);
|
||||
wrapper.appendChild(iframe);
|
||||
|
||||
const mount = () => {
|
||||
applyContentScriptUiPosition(wrapper, iframe, options);
|
||||
mountContentScriptUiRoot(wrapper, options);
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
wrapper.remove();
|
||||
};
|
||||
|
||||
ctx.onInvalidated(remove);
|
||||
|
||||
return {
|
||||
iframe,
|
||||
wrapper,
|
||||
mount,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContentScriptIframe {
|
||||
/**
|
||||
* The iframe added to the DOM.
|
||||
*/
|
||||
iframe: HTMLIFrameElement;
|
||||
/**
|
||||
* A wrapper div that assists in positioning.
|
||||
*/
|
||||
wrapper: HTMLDivElement;
|
||||
/**
|
||||
* Function that mounts or remounts the UI on the page.
|
||||
*/
|
||||
mount: () => void;
|
||||
/**
|
||||
* Function that removes the UI from the webpage.
|
||||
*/
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
export type ContentScriptIframeOptions = ContentScriptPositioningOptions &
|
||||
ContentScriptAnchoredOptions & {
|
||||
/**
|
||||
* The path to the unlisted HTML file to display in the iframe.
|
||||
*/
|
||||
page: import('wxt/browser').PublicPath;
|
||||
};
|
||||
@@ -1,173 +0,0 @@
|
||||
import { createIsolatedElement } from '@webext-core/isolated-element';
|
||||
import { browser } from '~/browser';
|
||||
import { logger } from '~/sandbox/utils/logger';
|
||||
import { ContentScriptContext } from './content-script-context';
|
||||
import {
|
||||
ContentScriptAnchoredOptions,
|
||||
ContentScriptPositioningOptions,
|
||||
applyContentScriptUiPosition,
|
||||
mountContentScriptUiRoot,
|
||||
} from '../utils/content-script-ui';
|
||||
|
||||
/**
|
||||
* Utility for mounting content script UI's with isolated styles. Automatically removed from the DOM
|
||||
* when the content script's context is invalidated.
|
||||
*
|
||||
* See https://wxt.dev/guide/content-script-ui.html for full documentation.
|
||||
*
|
||||
* @example
|
||||
* // entrypoints/example-ui.content/index.ts
|
||||
* import "./style.css"
|
||||
*
|
||||
* export default defineContentScript({
|
||||
* matches: ["*://*.google.com/*"],
|
||||
* cssInjectionMode: "ui",
|
||||
*
|
||||
* async main(ctx) {
|
||||
* const ui = await createContentScriptUi(ctx, {
|
||||
* name: "example-overlay",
|
||||
* type: "modal",
|
||||
* mount(container) {
|
||||
* const app = document.createElement("div");
|
||||
* app.textContent = "Content Script UI";
|
||||
* container.append(app);
|
||||
* }
|
||||
* })
|
||||
* ui.mount();
|
||||
* }
|
||||
* })
|
||||
*/
|
||||
export async function createContentScriptUi<TApp>(
|
||||
ctx: ContentScriptContext,
|
||||
options: ContentScriptUiOptions<TApp>,
|
||||
): Promise<ContentScriptUi<TApp>> {
|
||||
const css = [options.css ?? ''];
|
||||
if (ctx.options?.cssInjectionMode === 'ui') {
|
||||
css.push(await loadCss());
|
||||
}
|
||||
|
||||
const {
|
||||
isolatedElement: uiContainer,
|
||||
parentElement: shadowHost,
|
||||
shadow,
|
||||
} = await createIsolatedElement({
|
||||
name: options.name,
|
||||
css: {
|
||||
textContent: css.join('\n').trim(),
|
||||
},
|
||||
mode: 'open',
|
||||
});
|
||||
|
||||
let mounted: TApp;
|
||||
|
||||
const mount = () => {
|
||||
// Mount UI inside shadow root
|
||||
mounted = options.mount(uiContainer);
|
||||
|
||||
// Add shadow root element to DOM
|
||||
mountContentScriptUiRoot(shadowHost, options);
|
||||
applyContentScriptUiPosition(
|
||||
shadowHost,
|
||||
shadow.querySelector('html'),
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
// Detatch shadow root from DOM
|
||||
shadowHost.remove();
|
||||
// Cleanup mounted state
|
||||
options.onRemove?.(mounted);
|
||||
// Remove children from uiContainer
|
||||
while (uiContainer.lastChild)
|
||||
uiContainer.removeChild(uiContainer.lastChild);
|
||||
};
|
||||
|
||||
ctx.onInvalidated(remove);
|
||||
|
||||
return {
|
||||
shadow,
|
||||
shadowHost,
|
||||
uiContainer,
|
||||
mount,
|
||||
remove,
|
||||
mounted: mounted!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the CSS for the current entrypoint.
|
||||
*/
|
||||
async function loadCss(): Promise<string> {
|
||||
const url = browser.runtime.getURL(`/content-scripts/${__ENTRYPOINT__}.css`);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const css = await res.text();
|
||||
|
||||
// Replace :root selectors with :host since we're in a shadow root
|
||||
return css.replaceAll(':root', ':host');
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Failed to load styles @ ${url}. Did you forget to import the stylesheet in your entrypoint?`,
|
||||
err,
|
||||
);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ContentScriptUi<TApp> {
|
||||
/**
|
||||
* The `HTMLElement` hosting the shadow root used to isolate the UI's styles. This is the element
|
||||
* that get's added to the DOM. This element's style is not isolated from the webpage.
|
||||
*/
|
||||
shadowHost: HTMLElement;
|
||||
/**
|
||||
* The container element inside the `ShadowRoot` whose styles are isolated. The UI is mounted
|
||||
* inside this `HTMLElement`.
|
||||
*/
|
||||
uiContainer: HTMLElement;
|
||||
/**
|
||||
* The shadow root performing the isolation.
|
||||
*/
|
||||
shadow: ShadowRoot;
|
||||
/**
|
||||
* Custom data returned from the `options.mount` function.
|
||||
*/
|
||||
mounted: TApp;
|
||||
/**
|
||||
* Function that mounts or remounts the UI on the page.
|
||||
*/
|
||||
mount: () => void;
|
||||
/**
|
||||
* Function that removes the UI from the webpage.
|
||||
*/
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
export type ContentScriptUiOptions<TApp> = ContentScriptPositioningOptions &
|
||||
ContentScriptAnchoredOptions & {
|
||||
/**
|
||||
* The name of the custom component used to host the ShadowRoot. Must be kebab-case.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Callback executed when mounting the UI. This function should create and append the UI to the
|
||||
* `container` element. It is called every time `ui.mount()` is called
|
||||
*
|
||||
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
|
||||
*/
|
||||
mount: (container: Element) => TApp;
|
||||
/**
|
||||
* Callback called when the UI is removed from the webpage. Use to cleanup your UI, like
|
||||
* unmounting your vue or react apps.
|
||||
*/
|
||||
onRemove?: (mounted: TApp) => void;
|
||||
/**
|
||||
* Custom CSS text to apply to the UI. If your content script imports/generates CSS and you've
|
||||
* set `cssInjectionMode: "ui"`, the imported CSS will be included automatically. You do not need
|
||||
* to pass those styles in here. This is for any additional styles not in the imported CSS.
|
||||
*
|
||||
* See https://wxt.dev/guide/content-script-ui.html for more info.
|
||||
*/
|
||||
css?: string;
|
||||
};
|
||||
@@ -15,10 +15,12 @@ export class WxtLocationChangeEvent extends Event {
|
||||
* Returns an event name unique to the extension and content script that's running.
|
||||
*/
|
||||
export function getUniqueEventName(eventName: string): string {
|
||||
// During the build process, __ENTRYPOINT__ is not defined when importing entrypoints to get their
|
||||
// metadata.
|
||||
// During the build process, import.meta.env is not defined when importing
|
||||
// entrypoints to get their metadata.
|
||||
const entrypointName =
|
||||
typeof __ENTRYPOINT__ === 'undefined' ? 'build' : __ENTRYPOINT__;
|
||||
typeof import.meta.env === 'undefined'
|
||||
? 'build'
|
||||
: import.meta.env.ENTRYPOINT;
|
||||
|
||||
return `${browser.runtime.id}:${entrypointName}:${eventName}`;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './content-script-context';
|
||||
export * from './content-script-ui';
|
||||
export * from './content-script-iframe';
|
||||
export * from './ui';
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { describe, it, beforeEach, vi, expect } from 'vitest';
|
||||
import { createIntegratedUi, createIframeUi, createShadowRootUi } from '..';
|
||||
import { ContentScriptContext } from '../../content-script-context';
|
||||
|
||||
function appendTestApp(container: HTMLElement) {
|
||||
container.innerHTML = '<app>Hello world</app>';
|
||||
}
|
||||
|
||||
const fetch = vi.fn();
|
||||
|
||||
describe('Content Script UIs', () => {
|
||||
let ctx: ContentScriptContext;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="parent">
|
||||
<p id="one">one</p>
|
||||
<p id="two">two</p>
|
||||
<p id="three"></p>
|
||||
</div>
|
||||
`;
|
||||
window.fetch = fetch;
|
||||
fetch.mockResolvedValue({ text: () => Promise.resolve('') });
|
||||
ctx = new ContentScriptContext('test');
|
||||
});
|
||||
|
||||
describe('type', () => {
|
||||
describe('integrated', () => {
|
||||
it('should add a wrapper and custom UI to the page', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
expect(document.querySelector('app')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should allow customizing the wrapper tag', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
tag: 'pre',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('pre[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
expect(document.querySelector('app')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('iframe', () => {
|
||||
it('should add a wrapper and iframe to the page', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
page: '/page.html',
|
||||
position: 'inline',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('div[data-wxt-iframe]')).toBeDefined();
|
||||
expect(document.querySelector('iframe')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shadow-root', () => {
|
||||
it('should load a shadow root to the page', async () => {
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
position: 'inline',
|
||||
name: 'test',
|
||||
onMount(uiContainer) {
|
||||
appendTestApp(uiContainer);
|
||||
},
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('test[data-wxt-shadow-root]'),
|
||||
).not.toBeNull();
|
||||
expect(ui.shadow.querySelector('app')).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['open', 'open'],
|
||||
[undefined, 'open'],
|
||||
['closed', 'closed'],
|
||||
] as const)(
|
||||
'should respect the shadow root mode (%s -> %s)',
|
||||
async (input, expected) => {
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
position: 'inline',
|
||||
name: 'test',
|
||||
mode: input,
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
|
||||
expect(ui.shadow.mode).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('position', () => {
|
||||
describe('inline', () => {
|
||||
it('should wrap the UI in a simple div', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'inline',
|
||||
page: '/page.html',
|
||||
});
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe=""><iframe src="chrome-extension://test-extension-id/page.html"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('overlay', () => {
|
||||
it('should wrap the UI in a positioned div when alignment=undefined', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; top: 0px; left: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should wrap the UI in a positioned div when alignment=top-left', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
alignment: 'top-left',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; top: 0px; left: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should wrap the UI in a positioned div when alignment=top-right', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
alignment: 'top-right',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; top: 0px; right: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should wrap the UI in a positioned div when alignment=bottom-right', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
alignment: 'bottom-right',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; bottom: 0px; right: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should wrap the UI in a positioned div when alignment=bottom-left', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
alignment: 'bottom-left',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; bottom: 0px; left: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect the provided zIndex', () => {
|
||||
const zIndex = 123;
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
zIndex,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.style.zIndex).toBe(String(zIndex));
|
||||
});
|
||||
});
|
||||
|
||||
describe('modal', () => {
|
||||
it('should wrap the UI in a div with a fixed position', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'modal',
|
||||
page: '/page.html',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: fixed; top: 0px; bottom: 0px; left: 0px; right: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect the provided zIndex', () => {
|
||||
const zIndex = 123;
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'modal',
|
||||
page: '/page.html',
|
||||
zIndex,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.style.zIndex).toBe(String(zIndex));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('anchor', () => {
|
||||
describe('undefined', () => {
|
||||
it('should append the element to the body', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('body > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('string', () => {
|
||||
it('should append the element using the specified query selector', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
anchor: '#parent',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Element', () => {
|
||||
it('should append the element using the specified element', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
anchor: document.getElementById('parent'),
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('function', () => {
|
||||
it('should append the element using the specified function', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
anchor: () => document.getElementById('parent'),
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error when the anchor does not exist', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
anchor: () => document.getElementById('i-do-not-exist'),
|
||||
});
|
||||
|
||||
expect(ui.mount).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('append', () => {
|
||||
describe.each([undefined, 'last'] as const)('%s', (append) => {
|
||||
it('should append the element as the last child of the anchor', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#parent',
|
||||
append,
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'#parent > div[data-wxt-integrated]:last-child',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('first', () => {
|
||||
it('should append the element as the last child of the anchor', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#parent',
|
||||
append: 'first',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'#parent > div[data-wxt-integrated]:first-child',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('replace', () => {
|
||||
it('should replace the the anchor', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#parent',
|
||||
append: 'replace',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('body > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
expect(document.querySelector('#parent')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('before', () => {
|
||||
it('should append the UI before the anchor', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#one',
|
||||
append: 'before',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'#parent > div[data-wxt-integrated]:first-child',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.todo('after', () => {
|
||||
it('should append the UI after the anchor', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#three',
|
||||
append: 'after',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'#parent > div[data-wxt-integrated]:last-child',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('function', () => {
|
||||
it('should append the UI using a function', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#parent',
|
||||
append: (anchor, ui) => {
|
||||
anchor.replaceWith(ui);
|
||||
},
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('body > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
expect(document.querySelector('#parent')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { browser } from '~/browser';
|
||||
import { ContentScriptContext } from '..';
|
||||
import {
|
||||
ContentScriptAnchoredOptions,
|
||||
ContentScriptPositioningOptions,
|
||||
IframeContentScriptUi,
|
||||
IframeContentScriptUiOptions,
|
||||
IntegratedContentScriptUi,
|
||||
IntegratedContentScriptUiOptions,
|
||||
ShadowRootContentScriptUi,
|
||||
ShadowRootContentScriptUiOptions,
|
||||
} from './types';
|
||||
import { logger } from '~/sandbox/utils/logger';
|
||||
import { createIsolatedElement } from '@webext-core/isolated-element';
|
||||
export * from './types';
|
||||
|
||||
/**
|
||||
* Create a content script UI without any isolation.
|
||||
*
|
||||
* @see https://wxt.dev/guide/content-script-ui.html#integrated
|
||||
*/
|
||||
export function createIntegratedUi<TMounted>(
|
||||
ctx: ContentScriptContext,
|
||||
options: IntegratedContentScriptUiOptions<TMounted>,
|
||||
): IntegratedContentScriptUi<TMounted> {
|
||||
const wrapper = document.createElement(options.tag || 'div');
|
||||
wrapper.setAttribute('data-wxt-integrated', '');
|
||||
|
||||
let mounted: TMounted | undefined = undefined;
|
||||
const mount = () => {
|
||||
applyPosition(wrapper, undefined, options);
|
||||
mountUi(wrapper, options);
|
||||
mounted = options.onMount?.(wrapper);
|
||||
};
|
||||
const remove = () => {
|
||||
options.onRemove?.(mounted);
|
||||
wrapper.remove();
|
||||
};
|
||||
|
||||
ctx.onInvalidated(remove);
|
||||
|
||||
return {
|
||||
mounted,
|
||||
wrapper,
|
||||
mount,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a content script UI using an iframe.
|
||||
*
|
||||
* @see https://wxt.dev/guide/content-script-ui.html#iframe
|
||||
*/
|
||||
export function createIframeUi<TMounted>(
|
||||
ctx: ContentScriptContext,
|
||||
options: IframeContentScriptUiOptions<TMounted>,
|
||||
): IframeContentScriptUi<TMounted> {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.setAttribute('data-wxt-iframe', '');
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = browser.runtime.getURL(options.page);
|
||||
wrapper.appendChild(iframe);
|
||||
|
||||
let mounted: TMounted | undefined = undefined;
|
||||
const mount = () => {
|
||||
applyPosition(wrapper, iframe, options);
|
||||
mountUi(wrapper, options);
|
||||
mounted = options.onMount?.(wrapper, iframe);
|
||||
};
|
||||
const remove = () => {
|
||||
options.onRemove?.(mounted);
|
||||
wrapper.remove();
|
||||
};
|
||||
|
||||
ctx.onInvalidated(remove);
|
||||
|
||||
return {
|
||||
mounted,
|
||||
iframe,
|
||||
wrapper,
|
||||
mount,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a content script UI inside a [`ShadowRoot`](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot).
|
||||
*
|
||||
* > This function is async because it has to load the CSS via a network call.
|
||||
*
|
||||
* @see https://wxt.dev/guide/content-script-ui.html#shadowroot
|
||||
*/
|
||||
export async function createShadowRootUi<TMounted>(
|
||||
ctx: ContentScriptContext,
|
||||
options: ShadowRootContentScriptUiOptions<TMounted>,
|
||||
): Promise<ShadowRootContentScriptUi<TMounted>> {
|
||||
const css = [options.css ?? ''];
|
||||
if (ctx.options?.cssInjectionMode === 'ui') {
|
||||
const entryCss = await loadCss();
|
||||
// Replace :root selectors with :host since we're in a shadow root
|
||||
css.push(entryCss.replaceAll(':root', ':host'));
|
||||
}
|
||||
|
||||
const {
|
||||
isolatedElement: uiContainer,
|
||||
parentElement: shadowHost,
|
||||
shadow,
|
||||
} = await createIsolatedElement({
|
||||
name: options.name,
|
||||
css: {
|
||||
textContent: css.join('\n').trim(),
|
||||
},
|
||||
mode: options.mode ?? 'open',
|
||||
isolateEvents: options.isolateEvents,
|
||||
});
|
||||
shadowHost.setAttribute('data-wxt-shadow-root', '');
|
||||
|
||||
let mounted: TMounted;
|
||||
|
||||
const mount = () => {
|
||||
// Mount UI inside shadow root
|
||||
mounted = options.onMount(uiContainer, shadow, shadowHost);
|
||||
|
||||
// Add shadow root element to DOM
|
||||
mountUi(shadowHost, options);
|
||||
applyPosition(shadowHost, shadow.querySelector('html'), options);
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
// Detatch shadow root from DOM
|
||||
shadowHost.remove();
|
||||
// Cleanup mounted state
|
||||
options.onRemove?.(mounted);
|
||||
// Remove children from uiContainer
|
||||
while (uiContainer.lastChild)
|
||||
uiContainer.removeChild(uiContainer.lastChild);
|
||||
};
|
||||
|
||||
ctx.onInvalidated(remove);
|
||||
|
||||
return {
|
||||
shadow,
|
||||
shadowHost,
|
||||
uiContainer,
|
||||
mount,
|
||||
remove,
|
||||
mounted: mounted!,
|
||||
};
|
||||
}
|
||||
|
||||
function applyPosition(
|
||||
root: HTMLElement,
|
||||
positionedElement: HTMLElement | undefined | null,
|
||||
options: ContentScriptPositioningOptions,
|
||||
): void {
|
||||
// No positioning for inline UIs
|
||||
if (options.position === 'inline') return;
|
||||
|
||||
if (options.zIndex != null) root.style.zIndex = String(options.zIndex);
|
||||
|
||||
root.style.overflow = 'visible';
|
||||
root.style.position = 'relative';
|
||||
root.style.width = '0';
|
||||
root.style.height = '0';
|
||||
root.style.display = 'block';
|
||||
|
||||
if (positionedElement) {
|
||||
if (options.position === 'overlay') {
|
||||
positionedElement.style.position = 'absolute';
|
||||
if (options.alignment?.startsWith('bottom-'))
|
||||
positionedElement.style.bottom = '0';
|
||||
else positionedElement.style.top = '0';
|
||||
|
||||
if (options.alignment?.endsWith('-right'))
|
||||
positionedElement.style.right = '0';
|
||||
else positionedElement.style.left = '0';
|
||||
} else {
|
||||
positionedElement.style.position = 'fixed';
|
||||
positionedElement.style.top = '0';
|
||||
positionedElement.style.bottom = '0';
|
||||
positionedElement.style.left = '0';
|
||||
positionedElement.style.right = '0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAnchor(options: ContentScriptAnchoredOptions): Element | undefined {
|
||||
if (options.anchor == null) return document.body;
|
||||
|
||||
let resolved =
|
||||
typeof options.anchor === 'function' ? options.anchor() : options.anchor;
|
||||
if (typeof resolved === 'string')
|
||||
return document.querySelector<Element>(resolved) ?? undefined;
|
||||
return resolved ?? undefined;
|
||||
}
|
||||
|
||||
function mountUi(
|
||||
root: HTMLElement,
|
||||
options: ContentScriptAnchoredOptions,
|
||||
): void {
|
||||
const anchor = getAnchor(options);
|
||||
if (anchor == null)
|
||||
throw Error(
|
||||
'Failed to mount content script UI: could not find anchor element',
|
||||
);
|
||||
|
||||
switch (options.append) {
|
||||
case undefined:
|
||||
case 'last':
|
||||
anchor.append(root);
|
||||
break;
|
||||
case 'first':
|
||||
if (anchor.firstChild) {
|
||||
anchor.insertBefore(root, anchor.firstChild);
|
||||
} else {
|
||||
anchor.append(root);
|
||||
}
|
||||
break;
|
||||
case 'replace':
|
||||
anchor.replaceWith(root);
|
||||
break;
|
||||
case 'after':
|
||||
anchor.replaceWith(anchor, root);
|
||||
break;
|
||||
case 'before':
|
||||
anchor.replaceWith(root, anchor);
|
||||
break;
|
||||
default:
|
||||
options.append(anchor, root);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the CSS for the current entrypoint.
|
||||
*/
|
||||
async function loadCss(): Promise<string> {
|
||||
const url = browser.runtime.getURL(
|
||||
`/content-scripts/${import.meta.env.ENTRYPOINT}.css`,
|
||||
);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
return await res.text();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Failed to load styles @ ${url}. Did you forget to import the stylesheet in your entrypoint?`,
|
||||
err,
|
||||
);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
export interface IntegratedContentScriptUi<TMounted>
|
||||
extends ContentScriptUi<TMounted> {
|
||||
/**
|
||||
* A wrapper div that assists in positioning.
|
||||
*/
|
||||
wrapper: HTMLElement;
|
||||
}
|
||||
|
||||
export interface IframeContentScriptUi<TMounted>
|
||||
extends ContentScriptUi<TMounted> {
|
||||
/**
|
||||
* The iframe added to the DOM.
|
||||
*/
|
||||
iframe: HTMLIFrameElement;
|
||||
/**
|
||||
* A wrapper div that assists in positioning.
|
||||
*/
|
||||
wrapper: HTMLDivElement;
|
||||
}
|
||||
|
||||
export interface ShadowRootContentScriptUi<TMounted>
|
||||
extends ContentScriptUi<TMounted> {
|
||||
/**
|
||||
* The `HTMLElement` hosting the shadow root used to isolate the UI's styles. This is the element
|
||||
* that get's added to the DOM. This element's style is not isolated from the webpage.
|
||||
*/
|
||||
shadowHost: HTMLElement;
|
||||
/**
|
||||
* The container element inside the `ShadowRoot` whose styles are isolated. The UI is mounted
|
||||
* inside this `HTMLElement`.
|
||||
*/
|
||||
uiContainer: HTMLElement;
|
||||
/**
|
||||
* The shadow root performing the isolation.
|
||||
*/
|
||||
shadow: ShadowRoot;
|
||||
}
|
||||
|
||||
export interface ContentScriptUi<TMounted> {
|
||||
/**
|
||||
* Function that mounts or remounts the UI on the page.
|
||||
*/
|
||||
mount: () => void;
|
||||
/**
|
||||
* Function that removes the UI from the webpage.
|
||||
*/
|
||||
remove: () => void;
|
||||
/**>
|
||||
* Custom data returned from the `options.mount` function.
|
||||
*/
|
||||
mounted: TMounted | undefined;
|
||||
}
|
||||
|
||||
export type ContentScriptUiOptions<TMounted> = ContentScriptPositioningOptions &
|
||||
ContentScriptAnchoredOptions & {
|
||||
/**
|
||||
* Callback called before the UI is removed from the webpage. Use to cleanup your UI, like
|
||||
* unmounting your Vue or React apps.
|
||||
*/
|
||||
onRemove?: (mounted: TMounted | undefined) => void;
|
||||
};
|
||||
|
||||
export type IntegratedContentScriptUiOptions<TMounted> =
|
||||
ContentScriptUiOptions<TMounted> & {
|
||||
/**
|
||||
* Tag used to create the wrapper element.
|
||||
*
|
||||
* @default "div"
|
||||
*/
|
||||
tag?: string;
|
||||
/**
|
||||
* Callback executed when mounting the UI. This function should create and append the UI to the
|
||||
* `wrapper` element. It is called every time `ui.mount()` is called.
|
||||
*
|
||||
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
|
||||
*/
|
||||
onMount: (wrapper: HTMLElement) => TMounted;
|
||||
};
|
||||
|
||||
export type IframeContentScriptUiOptions<TMounted> =
|
||||
ContentScriptUiOptions<TMounted> & {
|
||||
/**
|
||||
* The path to the HTML page that will be shown in the iframe. This string is passed into
|
||||
* `browser.runtime.getURL`.
|
||||
*/
|
||||
page: PublicPath;
|
||||
/**
|
||||
* Callback executed when mounting the UI. Use this function to customize the iframe or wrapper
|
||||
* element's appearance. It is called every time `ui.mount()` is called.
|
||||
*
|
||||
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
|
||||
*/
|
||||
onMount?: (wrapper: HTMLElement, iframe: HTMLIFrameElement) => TMounted;
|
||||
};
|
||||
|
||||
export type ShadowRootContentScriptUiOptions<TMounted> =
|
||||
ContentScriptUiOptions<TMounted> & {
|
||||
/**
|
||||
* The name of the custom component used to host the ShadowRoot. Must be kebab-case.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Custom CSS text to apply to the UI. If your content script imports/generates CSS and you've
|
||||
* set `cssInjectionMode: "ui"`, the imported CSS will be included automatically. You do not need
|
||||
* to pass those styles in here. This is for any additional styles not in the imported CSS.
|
||||
*/
|
||||
css?: string;
|
||||
/**
|
||||
* ShadowRoot's mode.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/mode
|
||||
* @default "open"
|
||||
*/
|
||||
mode?: 'open' | 'closed';
|
||||
/**
|
||||
* When enabled, `event.stopPropagation` will be called on events trying to bubble out of the
|
||||
* shadow root.
|
||||
*
|
||||
* - Set to `true` to stop the propagation of a default set of events,
|
||||
* `["keyup", "keydown", "keypress"]`
|
||||
* - Set to an array of event names to stop the propagation of a custom list of events
|
||||
*/
|
||||
isolateEvents?: boolean | string[];
|
||||
/**
|
||||
* Callback executed when mounting the UI. This function should create and append the UI to the
|
||||
* `uiContainer` element. It is called every time `ui.mount()` is called.
|
||||
*
|
||||
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
|
||||
*/
|
||||
onMount: (
|
||||
uiContainer: HTMLElement,
|
||||
shadow: ShadowRoot,
|
||||
shadowHost: HTMLElement,
|
||||
) => TMounted;
|
||||
};
|
||||
|
||||
export type ContentScriptOverlayAlignment =
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right';
|
||||
|
||||
/**
|
||||
* 
|
||||
*/
|
||||
export type ContentScriptAppendMode =
|
||||
| 'last'
|
||||
| 'first'
|
||||
| 'replace'
|
||||
| 'before'
|
||||
| 'after'
|
||||
| ((anchor: Element, ui: Element) => void);
|
||||
|
||||
export interface ContentScriptInlinePositioningOptions {
|
||||
position: 'inline';
|
||||
}
|
||||
|
||||
export interface ContentScriptOverlayPositioningOptions {
|
||||
position: 'overlay';
|
||||
/**
|
||||
* The `z-index` used on the `wrapper` element. Set to a positive number to show your UI over website
|
||||
* content.
|
||||
*/
|
||||
zIndex?: number;
|
||||
/**
|
||||
* When using `type: "overlay"`, the mounted element is 0px by 0px in size. Alignment specifies
|
||||
* which corner is aligned with that 0x0 pixel space.
|
||||
*
|
||||
* 
|
||||
*
|
||||
* @default "top-left"
|
||||
*/
|
||||
alignment?: ContentScriptOverlayAlignment;
|
||||
}
|
||||
|
||||
export interface ContentScriptModalPositioningOptions {
|
||||
position: 'modal';
|
||||
/**
|
||||
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
|
||||
* content.
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose between `"inline"`, `"overlay"`, or `"modal" `types.
|
||||
*
|
||||
* 
|
||||
*/
|
||||
export type ContentScriptPositioningOptions =
|
||||
| ContentScriptInlinePositioningOptions
|
||||
| ContentScriptOverlayPositioningOptions
|
||||
| ContentScriptModalPositioningOptions;
|
||||
|
||||
export interface ContentScriptAnchoredOptions {
|
||||
/**
|
||||
* A CSS selector, element, or function that returns one of the two. Along with `append`, the
|
||||
* `anchor` dictates where in the page the UI will be added.
|
||||
*/
|
||||
anchor?:
|
||||
| string
|
||||
| Element
|
||||
| null
|
||||
| undefined
|
||||
| (() => string | Element | null | undefined);
|
||||
/**
|
||||
* In combination with `anchor`, decide how to add the UI to the DOM.
|
||||
*
|
||||
* - `"last"` (default) - Add the UI as the last child of the `anchor` element
|
||||
* - `"first"` - Add the UI as the last child of the `anchor` element
|
||||
* - `"replace"` - Replace the `anchor` element with the UI.
|
||||
* - `"before"` - Add the UI as the sibling before the `anchor` element
|
||||
* - `"after"` - Add the UI as the sibling after the `anchor` element
|
||||
* - `(anchor, ui) => void` - Customizable function that let's you add the UI to the DOM
|
||||
*/
|
||||
append?: ContentScriptAppendMode | ((anchor: Element, ui: Element) => void);
|
||||
}
|
||||
@@ -4,12 +4,3 @@
|
||||
* @module wxt/client
|
||||
*/
|
||||
export * from './content-scripts';
|
||||
export {
|
||||
ContentScriptOverlayAlignment,
|
||||
ContentScriptAppendMode,
|
||||
ContentScriptPositioningOptions,
|
||||
ContentScriptAnchoredOptions,
|
||||
ContentScriptInlinePositioningOptions,
|
||||
ContentScriptOverlayPositioningOptions,
|
||||
ContentScriptModalPositioningOptions,
|
||||
} from './utils/content-script-ui';
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
export type ContentScriptOverlayAlignment =
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right';
|
||||
|
||||
/**
|
||||
* 
|
||||
*/
|
||||
export type ContentScriptAppendMode =
|
||||
| 'last'
|
||||
| 'first'
|
||||
| 'replace'
|
||||
| 'before'
|
||||
| 'after'
|
||||
| ((anchor: Element, ui: Element) => void);
|
||||
|
||||
export function mountContentScriptUiRoot(
|
||||
root: HTMLElement,
|
||||
options: ContentScriptAnchoredOptions,
|
||||
): void {
|
||||
const anchor = getAnchor(options);
|
||||
if (anchor == null)
|
||||
throw Error(
|
||||
'Failed to mount content script UI: could not find anchor element',
|
||||
);
|
||||
|
||||
switch (options.append) {
|
||||
case undefined:
|
||||
case 'last':
|
||||
anchor.append(root);
|
||||
break;
|
||||
case 'first':
|
||||
if (anchor.firstChild) {
|
||||
anchor.insertBefore(root, anchor.firstChild);
|
||||
} else {
|
||||
anchor.append(root);
|
||||
}
|
||||
break;
|
||||
case 'replace':
|
||||
anchor.replaceWith(root);
|
||||
break;
|
||||
case 'after':
|
||||
anchor.replaceWith(anchor, root);
|
||||
break;
|
||||
case 'before':
|
||||
anchor.replaceWith(root, anchor);
|
||||
break;
|
||||
default:
|
||||
options.append(anchor, root);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export 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.
|
||||
*
|
||||
* 
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* 
|
||||
*/
|
||||
export type ContentScriptPositioningOptions =
|
||||
| ContentScriptInlinePositioningOptions
|
||||
| ContentScriptOverlayPositioningOptions
|
||||
| ContentScriptModalPositioningOptions;
|
||||
|
||||
export function applyContentScriptUiPosition(
|
||||
root: HTMLElement,
|
||||
positionedElement: HTMLElement | undefined | null,
|
||||
options: ContentScriptPositioningOptions,
|
||||
): void {
|
||||
if (options.type !== 'inline') {
|
||||
if (options.zIndex != null) root.style.zIndex = String(options.zIndex);
|
||||
|
||||
root.style.overflow = 'visible';
|
||||
root.style.position = 'relative';
|
||||
root.style.width = '0';
|
||||
root.style.height = '0';
|
||||
root.style.display = 'block';
|
||||
|
||||
if (positionedElement) {
|
||||
if (options.type === 'overlay') {
|
||||
positionedElement.style.position = 'absolute';
|
||||
if (options.alignment?.startsWith('bottom-'))
|
||||
positionedElement.style.bottom = '0';
|
||||
else positionedElement.style.top = '0';
|
||||
|
||||
if (options.alignment?.endsWith('-right'))
|
||||
positionedElement.style.right = '0';
|
||||
else positionedElement.style.left = '0';
|
||||
} else {
|
||||
positionedElement.style.position = 'fixed';
|
||||
positionedElement.style.top = '0';
|
||||
positionedElement.style.bottom = '0';
|
||||
positionedElement.style.left = '0';
|
||||
positionedElement.style.right = '0';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAnchor(options: ContentScriptAnchoredOptions): Element | undefined {
|
||||
if (options.anchor == null) return document.body;
|
||||
|
||||
let resolved =
|
||||
typeof options.anchor === 'function' ? options.anchor() : options.anchor;
|
||||
if (typeof resolved === 'string')
|
||||
return document.querySelector<Element>(resolved) ?? undefined;
|
||||
return resolved ?? undefined;
|
||||
}
|
||||
|
||||
export interface ContentScriptAnchoredOptions {
|
||||
/**
|
||||
* A CSS selector, element, or function that returns one of the two. Along with `append`, the
|
||||
* `anchor` dictates where in the page the UI will be added.
|
||||
*/
|
||||
anchor?:
|
||||
| string
|
||||
| Element
|
||||
| null
|
||||
| undefined
|
||||
| (() => string | Element | null | undefined);
|
||||
/**
|
||||
* In combination with `anchor`, decide how to add the UI to the DOM.
|
||||
*
|
||||
* - `"last"` (default) - Add the UI as the last child of the `anchor` element
|
||||
* - `"first"` - Add the UI as the last child of the `anchor` element
|
||||
* - `"replace"` - Replace the `anchor` element with the UI.
|
||||
* - `"before"` - Add the UI as the sibling before the `anchor` element
|
||||
* - `"after"` - Add the UI as the sibling after the `anchor` element
|
||||
* - `(anchor, ui) => void` - Customizable function that let's you add the UI to the DOM
|
||||
*/
|
||||
append?: ContentScriptAppendMode | ((anchor: Element, ui: Element) => void);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import * as wxtPlugins from './plugins';
|
||||
import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
|
||||
|
||||
export async function craeteViteBuilder(
|
||||
export async function createViteBuilder(
|
||||
inlineConfig: InlineConfig,
|
||||
userConfig: UserConfig,
|
||||
wxtConfig: Omit<InternalConfig, 'builder'>,
|
||||
@@ -40,6 +40,14 @@ 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;
|
||||
}
|
||||
// Enable inline sourcemaps for the dev command (so content scripts have sourcemaps)
|
||||
if (config.build.sourcemap == null && wxtConfig.command === 'serve') {
|
||||
config.build.sourcemap = 'inline';
|
||||
}
|
||||
|
||||
config.plugins ??= [];
|
||||
config.plugins.push(
|
||||
@@ -226,6 +234,9 @@ export async function craeteViteBuilder(
|
||||
async listen() {
|
||||
await viteServer.listen(info.port);
|
||||
},
|
||||
async close() {
|
||||
await viteServer.close();
|
||||
},
|
||||
transformHtml(...args) {
|
||||
return viteServer.transformIndexHtml(...args);
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ export function entrypointGroupGlobals(
|
||||
const define: vite.InlineConfig['define'] = {};
|
||||
let name = Array.isArray(entrypointGroup) ? 'html' : entrypointGroup.name;
|
||||
for (const global of getEntrypointGlobals(name)) {
|
||||
define[global.name] = JSON.stringify(global.value);
|
||||
define[`import.meta.env.${global.name}`] = JSON.stringify(global.value);
|
||||
}
|
||||
return {
|
||||
define,
|
||||
|
||||
@@ -10,7 +10,7 @@ export function globals(
|
||||
config() {
|
||||
const define: vite.InlineConfig['define'] = {};
|
||||
for (const global of getGlobals(config)) {
|
||||
define[global.name] = JSON.stringify(global.value);
|
||||
define[`import.meta.env.${global.name}`] = JSON.stringify(global.value);
|
||||
}
|
||||
return {
|
||||
define,
|
||||
|
||||
@@ -36,7 +36,11 @@ export function unimport(
|
||||
// Don't transform non-js files
|
||||
if (!ENABLED_EXTENSIONS.has(extname(id))) return;
|
||||
|
||||
return unimport.injectImports(code, id);
|
||||
const injected = await unimport.injectImports(code, id);
|
||||
return {
|
||||
code: injected.code,
|
||||
map: injected.s.generateMap({ hires: 'boundary', source: id }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+68
-16
@@ -20,6 +20,7 @@ import {
|
||||
getInternalConfig,
|
||||
detectDevChanges,
|
||||
rebuild,
|
||||
findEntrypoints,
|
||||
} from '~/core/utils/building';
|
||||
import { createExtensionRunner } from '~/core/runners';
|
||||
import { consola } from 'consola';
|
||||
@@ -48,22 +49,46 @@ export async function createServer(
|
||||
origin,
|
||||
};
|
||||
|
||||
const buildAndOpenBrowser = async () => {
|
||||
// Build after starting the dev server so it can be used to transform HTML files
|
||||
server.currentOutput = await internalBuild(config);
|
||||
|
||||
// Open browser after everything is ready to go.
|
||||
await runner.openBrowser(config);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops the previous runner, grabs the latest config, and recreates the runner.
|
||||
*/
|
||||
const closeAndRecreateRunner = async () => {
|
||||
await runner.closeBrowser();
|
||||
config = await getLatestConfig();
|
||||
runner = await createExtensionRunner(config);
|
||||
};
|
||||
|
||||
// Server instance must be created first so its reference can be added to the internal config used
|
||||
// to pre-render entrypoints
|
||||
const server: WxtDevServer = {
|
||||
...serverInfo,
|
||||
watcher: undefined as any, // Filled out later down below
|
||||
ws: undefined as any, // Filled out later down below
|
||||
currentOutput: undefined as any, // Filled out later down below
|
||||
get watcher() {
|
||||
return builderServer.watcher;
|
||||
},
|
||||
get ws() {
|
||||
return builderServer.ws;
|
||||
},
|
||||
currentOutput: undefined,
|
||||
async start() {
|
||||
await builderServer.listen();
|
||||
config.logger.success(`Started dev server @ ${serverInfo.origin}`);
|
||||
|
||||
// Build after starting the dev server so it can be used to transform HTML files
|
||||
server.currentOutput = await internalBuild(config);
|
||||
|
||||
// Open browser after everything is ready to go.
|
||||
await runner.openBrowser(config);
|
||||
await buildAndOpenBrowser();
|
||||
},
|
||||
async stop() {
|
||||
await runner.closeBrowser();
|
||||
await builderServer.close();
|
||||
},
|
||||
async restart() {
|
||||
await closeAndRecreateRunner();
|
||||
await buildAndOpenBrowser();
|
||||
},
|
||||
transformHtml(url, html, originalUrl) {
|
||||
return builderServer.transformHtml(url, html, originalUrl);
|
||||
@@ -77,23 +102,25 @@ export async function createServer(
|
||||
reloadExtension() {
|
||||
server.ws.send('wxt:reload-extension');
|
||||
},
|
||||
async restartBrowser() {
|
||||
await closeAndRecreateRunner();
|
||||
await runner.openBrowser(config);
|
||||
},
|
||||
};
|
||||
|
||||
const getLatestConfig = () =>
|
||||
getInternalConfig(inlineConfig ?? {}, 'serve', server);
|
||||
let config = await getLatestConfig();
|
||||
|
||||
const [runner, builderServer] = await Promise.all([
|
||||
let [runner, builderServer] = await Promise.all([
|
||||
createExtensionRunner(config),
|
||||
config.builder.createServer(server),
|
||||
]);
|
||||
|
||||
server.watcher = builderServer.watcher;
|
||||
server.ws = builderServer.ws;
|
||||
|
||||
// Register content scripts for the first time after the background starts up since they're not
|
||||
// listed in the manifest
|
||||
server.ws.on('wxt:background-initialized', () => {
|
||||
if (server.currentOutput == null) return;
|
||||
reloadContentScripts(server.currentOutput.steps, config, server);
|
||||
});
|
||||
|
||||
@@ -137,18 +164,39 @@ function createFileReloader(options: {
|
||||
changeQueue.push([event, path]);
|
||||
|
||||
await fileChangedMutex.runExclusive(async () => {
|
||||
const fileChanges = changeQueue.splice(0, changeQueue.length);
|
||||
if (server.currentOutput == null) return;
|
||||
|
||||
const fileChanges = changeQueue
|
||||
.splice(0, changeQueue.length)
|
||||
.map(([_, file]) => file);
|
||||
if (fileChanges.length === 0) return;
|
||||
|
||||
const changes = detectDevChanges(fileChanges, server.currentOutput);
|
||||
const changes = detectDevChanges(
|
||||
config,
|
||||
fileChanges,
|
||||
server.currentOutput,
|
||||
);
|
||||
if (changes.type === 'no-change') return;
|
||||
|
||||
if (changes.type === 'full-restart') {
|
||||
config.logger.info('Config changed, restarting server...');
|
||||
server.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (changes.type === 'browser-restart') {
|
||||
config.logger.info('Runner config changed, restarting browser...');
|
||||
server.restartBrowser();
|
||||
return;
|
||||
}
|
||||
|
||||
// Log the entrypoints that were effected
|
||||
config.logger.info(
|
||||
`Changed: ${Array.from(new Set(fileChanges.map((change) => change[1])))
|
||||
`Changed: ${Array.from(new Set(fileChanges))
|
||||
.map((file) => pc.dim(relative(config.root, file)))
|
||||
.join(', ')}`,
|
||||
);
|
||||
|
||||
const rebuiltNames = changes.rebuildGroups
|
||||
.flat()
|
||||
.map((entry) => {
|
||||
@@ -159,8 +207,10 @@ function createFileReloader(options: {
|
||||
.join(pc.dim(', '));
|
||||
|
||||
// Rebuild entrypoints on change
|
||||
const allEntrypoints = await findEntrypoints(config);
|
||||
const { output: newOutput } = await rebuild(
|
||||
config,
|
||||
allEntrypoints,
|
||||
// TODO: this excludes new entrypoints, so they're not built until the dev command is restarted
|
||||
changes.rebuildGroups,
|
||||
changes.cachedOutput,
|
||||
@@ -194,6 +244,8 @@ function reloadContentScripts(
|
||||
) {
|
||||
if (config.manifestVersion === 3) {
|
||||
steps.forEach((step) => {
|
||||
if (server.currentOutput == null) return;
|
||||
|
||||
const entry = step.entrypoints;
|
||||
if (Array.isArray(entry) || entry.type !== 'content-script') return;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { WebExtRunInstance } from 'web-ext-run';
|
||||
import { ExtensionRunner } from '~/types';
|
||||
import { formatDuration } from '../utils/time';
|
||||
|
||||
/**
|
||||
* Create an `ExtensionRunner` backed by `web-ext`.
|
||||
@@ -9,7 +10,7 @@ export function createWebExtRunner(): ExtensionRunner {
|
||||
|
||||
return {
|
||||
async openBrowser(config) {
|
||||
config.logger.info('Opening browser...');
|
||||
const startTime = Date.now();
|
||||
|
||||
if (config.browser === 'firefox' && config.manifestVersion === 3) {
|
||||
throw Error(
|
||||
@@ -17,7 +18,7 @@ export function createWebExtRunner(): ExtensionRunner {
|
||||
);
|
||||
}
|
||||
|
||||
// Use the plugin's logger instead of web-ext's built-in one.
|
||||
// Use WXT's logger instead of web-ext's built-in one.
|
||||
const webExtLogger = await import('web-ext-run/util/logger');
|
||||
webExtLogger.consoleStream.write = ({ level, msg, name }) => {
|
||||
if (level >= ERROR_LOG_LEVEL) config.logger.error(name, msg);
|
||||
@@ -61,7 +62,8 @@ export function createWebExtRunner(): ExtensionRunner {
|
||||
const webExt = await import('web-ext-run');
|
||||
runner = await webExt.default.cmd.run(finalConfig, options);
|
||||
|
||||
config.logger.success('Opened!');
|
||||
const duration = Date.now() - startTime;
|
||||
config.logger.success(`Opened browser in ${formatDuration(duration)}`);
|
||||
},
|
||||
|
||||
async closeBrowser() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { every } from '~/core/utils/arrays';
|
||||
import { every, some } from '~/core/utils/arrays';
|
||||
|
||||
describe('Array Utils', () => {
|
||||
describe('every', () => {
|
||||
@@ -15,4 +15,20 @@ describe('Array Utils', () => {
|
||||
expect(every([1, 2, 1], (item) => item === 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('some', () => {
|
||||
it('should return true if one value returns true', () => {
|
||||
const array = [1, 2, 3];
|
||||
const predicate = (item: number) => item === 2;
|
||||
|
||||
expect(some(array, predicate)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if no values match', () => {
|
||||
const array = [1, 2, 3];
|
||||
const predicate = (item: number) => item === 4;
|
||||
|
||||
expect(some(array, predicate)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('Entrypoint Utils', () => {
|
||||
name,
|
||||
outputDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
|
||||
const actual = getEntrypointOutputFile(entrypoint, ext);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
import { defineContentScript } from '~/sandbox';
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: [faker.string.nanoid()],
|
||||
main() {},
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
fakeArray,
|
||||
fakeContentScriptEntrypoint,
|
||||
fakeEntrypoint,
|
||||
fakeGenericEntrypoint,
|
||||
} from '../testing/fake-objects';
|
||||
import { validateEntrypoints } from '../validation';
|
||||
|
||||
describe('Validation Utils', () => {
|
||||
describe('validateEntrypoints', () => {
|
||||
it('should return no errors when there are no errors', () => {
|
||||
const entrypoints = fakeArray(fakeEntrypoint);
|
||||
const expected = {
|
||||
errors: [],
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
};
|
||||
|
||||
const actual = validateEntrypoints(entrypoints);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should return an error when exclude is not an array', () => {
|
||||
const entrypoint = fakeGenericEntrypoint({
|
||||
options: {
|
||||
// @ts-expect-error
|
||||
exclude: 0,
|
||||
},
|
||||
});
|
||||
const expected = {
|
||||
errors: [
|
||||
{
|
||||
type: 'error',
|
||||
message: '`exclude` must be an array of browser names',
|
||||
value: 0,
|
||||
entrypoint,
|
||||
},
|
||||
],
|
||||
errorCount: 1,
|
||||
warningCount: 0,
|
||||
};
|
||||
|
||||
const actual = validateEntrypoints([entrypoint]);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should return an error when include is not an array', () => {
|
||||
const entrypoint = fakeGenericEntrypoint({
|
||||
options: {
|
||||
// @ts-expect-error
|
||||
include: 0,
|
||||
},
|
||||
});
|
||||
const expected = {
|
||||
errors: [
|
||||
{
|
||||
type: 'error',
|
||||
message: '`include` must be an array of browser names',
|
||||
value: 0,
|
||||
entrypoint,
|
||||
},
|
||||
],
|
||||
errorCount: 1,
|
||||
warningCount: 0,
|
||||
};
|
||||
|
||||
const actual = validateEntrypoints([entrypoint]);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("should return an error when content scripts don't have a matches", () => {
|
||||
const entrypoint = fakeContentScriptEntrypoint({
|
||||
options: {
|
||||
// @ts-expect-error
|
||||
matches: null,
|
||||
},
|
||||
});
|
||||
const expected = {
|
||||
errors: [
|
||||
{
|
||||
type: 'error',
|
||||
message: '`matches` is required',
|
||||
value: null,
|
||||
entrypoint,
|
||||
},
|
||||
],
|
||||
errorCount: 1,
|
||||
warningCount: 0,
|
||||
};
|
||||
|
||||
const actual = validateEntrypoints([entrypoint]);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,3 +9,15 @@ export function every<T>(
|
||||
if (!predicate(array[i], i)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when any of the predicates return true;
|
||||
*/
|
||||
export function some<T>(
|
||||
array: T[],
|
||||
predicate: (item: T, index: number) => boolean,
|
||||
): boolean {
|
||||
for (let i = 0; i < array.length; i++)
|
||||
if (predicate(array[i], i)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -10,22 +10,15 @@ import {
|
||||
fakePopupEntrypoint,
|
||||
fakeOutputAsset,
|
||||
fakeOutputChunk,
|
||||
fakeInternalConfig,
|
||||
} from '~/core/utils/testing/fake-objects';
|
||||
import { BuildOutput, BuildStepOutput } from '~/types';
|
||||
|
||||
describe('Detect Dev Changes', () => {
|
||||
describe('No changes', () => {
|
||||
it("should return 'no-change' when a build hasn't finished", () => {
|
||||
const actual = detectDevChanges(
|
||||
[['unknown', '/path/to/file.ts']],
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(actual).toEqual({ type: 'no-change' });
|
||||
});
|
||||
|
||||
it("should return 'no-change' when the changed file isn't used by any of the entrypoints", () => {
|
||||
const change: [string, string] = ['unknown', '/some/path.ts'];
|
||||
const config = fakeInternalConfig();
|
||||
const changes = ['/some/path.ts'];
|
||||
const currentOutput: BuildOutput = {
|
||||
manifest: fakeManifest(),
|
||||
publicAssets: [],
|
||||
@@ -41,18 +34,64 @@ describe('Detect Dev Changes', () => {
|
||||
],
|
||||
};
|
||||
|
||||
const actual = detectDevChanges([change], currentOutput);
|
||||
const actual = detectDevChanges(config, changes, currentOutput);
|
||||
|
||||
expect(actual).toEqual({ type: 'no-change' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('wxt.config.ts', () => {
|
||||
it("should return 'full-restart' when one of the changed files is the config file", () => {
|
||||
const configFile = '/root/wxt.config.ts';
|
||||
const config = fakeInternalConfig({
|
||||
userConfigMetadata: {
|
||||
configFile,
|
||||
},
|
||||
});
|
||||
const changes = ['/root/src/public/image.svg', configFile];
|
||||
const currentOutput: BuildOutput = {
|
||||
manifest: fakeManifest(),
|
||||
publicAssets: [],
|
||||
steps: [],
|
||||
};
|
||||
const expected: DevModeChange = {
|
||||
type: 'full-restart',
|
||||
};
|
||||
|
||||
const actual = detectDevChanges(config, changes, currentOutput);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('web-ext.config.ts', () => {
|
||||
it("should return 'browser-restart' when one of the changed files is the config file", () => {
|
||||
const runnerFile = '/root/web-ext.config.ts';
|
||||
const config = fakeInternalConfig({
|
||||
runnerConfig: {
|
||||
configFile: runnerFile,
|
||||
},
|
||||
});
|
||||
const changes = ['/root/src/public/image.svg', runnerFile];
|
||||
const currentOutput: BuildOutput = {
|
||||
manifest: fakeManifest(),
|
||||
publicAssets: [],
|
||||
steps: [],
|
||||
};
|
||||
const expected: DevModeChange = {
|
||||
type: 'browser-restart',
|
||||
};
|
||||
|
||||
const actual = detectDevChanges(config, changes, currentOutput);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Public Assets', () => {
|
||||
it("should return 'extension-reload' without any groups to rebuild when the changed file is a public asset", () => {
|
||||
const change: [string, string] = [
|
||||
'unknown',
|
||||
'/root/src/public/image.svg',
|
||||
];
|
||||
const config = fakeInternalConfig();
|
||||
const changes = ['/root/src/public/image.svg'];
|
||||
const asset1 = fakeOutputAsset({
|
||||
fileName: 'image.svg',
|
||||
});
|
||||
@@ -73,7 +112,7 @@ describe('Detect Dev Changes', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const actual = detectDevChanges([change], currentOutput);
|
||||
const actual = detectDevChanges(config, changes, currentOutput);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
@@ -81,6 +120,7 @@ describe('Detect Dev Changes', () => {
|
||||
|
||||
describe('Background', () => {
|
||||
it("should rebuild the background and reload the extension when the changed file in it's chunks' `moduleIds` field", () => {
|
||||
const config = fakeInternalConfig();
|
||||
const changedPath = '/root/utils/shared.ts';
|
||||
const contentScript = fakeContentScriptEntrypoint({
|
||||
inputPath: '/root/overlay.content.ts',
|
||||
@@ -120,10 +160,7 @@ describe('Detect Dev Changes', () => {
|
||||
rebuildGroups: [background],
|
||||
};
|
||||
|
||||
const actual = detectDevChanges(
|
||||
[['unknown', changedPath]],
|
||||
currentOutput,
|
||||
);
|
||||
const actual = detectDevChanges(config, [changedPath], currentOutput);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
@@ -131,6 +168,7 @@ describe('Detect Dev Changes', () => {
|
||||
|
||||
describe('HTML Pages', () => {
|
||||
it('should rebuild then reload only the effected pages', async () => {
|
||||
const config = fakeInternalConfig();
|
||||
const changedPath = '/root/page1/index.html';
|
||||
const htmlPage1 = fakePopupEntrypoint({
|
||||
inputPath: changedPath,
|
||||
@@ -174,10 +212,7 @@ describe('Detect Dev Changes', () => {
|
||||
rebuildGroups: [[htmlPage1, htmlPage2]],
|
||||
};
|
||||
|
||||
const actual = detectDevChanges(
|
||||
[['unknown', changedPath]],
|
||||
currentOutput,
|
||||
);
|
||||
const actual = detectDevChanges(config, [changedPath], currentOutput);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
@@ -185,6 +220,7 @@ describe('Detect Dev Changes', () => {
|
||||
|
||||
describe('Content Scripts', () => {
|
||||
it('should rebuild then reload only the effected content scripts', async () => {
|
||||
const config = fakeInternalConfig();
|
||||
const changedPath = '/root/utils/shared.ts';
|
||||
const script1 = fakeContentScriptEntrypoint({
|
||||
inputPath: '/root/overlay1.content/index.ts',
|
||||
@@ -236,10 +272,7 @@ describe('Detect Dev Changes', () => {
|
||||
rebuildGroups: [script1, script3],
|
||||
};
|
||||
|
||||
const actual = detectDevChanges(
|
||||
[['unknown', changedPath]],
|
||||
currentOutput,
|
||||
);
|
||||
const actual = detectDevChanges(config, [changedPath], currentOutput);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('findEntrypoints', () => {
|
||||
defaultIcon: { '16': '/icon/16.png' },
|
||||
defaultTitle: 'Default Title',
|
||||
},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -72,6 +73,7 @@ describe('findEntrypoints', () => {
|
||||
options: {
|
||||
defaultTitle: 'Title',
|
||||
},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
])(
|
||||
@@ -103,6 +105,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'options.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -123,6 +126,7 @@ describe('findEntrypoints', () => {
|
||||
options: {
|
||||
openInTab: true,
|
||||
},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
])(
|
||||
@@ -146,6 +150,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'content',
|
||||
inputPath: resolve(config.entrypointsDir, 'content.ts'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -155,6 +160,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'overlay',
|
||||
inputPath: resolve(config.entrypointsDir, 'overlay.content.ts'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -164,6 +170,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'content',
|
||||
inputPath: resolve(config.entrypointsDir, 'content/index.ts'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -173,6 +180,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'overlay',
|
||||
inputPath: resolve(config.entrypointsDir, 'overlay.content/index.ts'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -182,6 +190,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'overlay',
|
||||
inputPath: resolve(config.entrypointsDir, 'overlay.content.tsx'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
])(
|
||||
@@ -212,6 +221,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'background',
|
||||
inputPath: resolve(config.entrypointsDir, 'background.ts'),
|
||||
outputDir: config.outDir,
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -221,6 +231,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'background',
|
||||
inputPath: resolve(config.entrypointsDir, 'background/index.ts'),
|
||||
outputDir: config.outDir,
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
])(
|
||||
@@ -258,6 +269,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'background',
|
||||
options: {},
|
||||
outputDir: config.outDir,
|
||||
skipped: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -278,6 +290,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'injected',
|
||||
inputPath: resolve(config.entrypointsDir, path),
|
||||
outputDir: config.outDir,
|
||||
skipped: false,
|
||||
};
|
||||
const options: GenericEntrypoint['options'] = {};
|
||||
globMock.mockResolvedValueOnce([path]);
|
||||
@@ -304,6 +317,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'sandbox.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -314,6 +328,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'sandbox/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -324,6 +339,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'named.sandbox.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -334,6 +350,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'named.sandbox/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -346,6 +363,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'bookmarks.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -356,6 +374,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'bookmarks/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -368,6 +387,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'history.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -378,6 +398,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'history/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -390,6 +411,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'newtab.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -400,6 +422,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'newtab/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -412,6 +435,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'sidepanel.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -422,6 +446,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'sidepanel/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -432,6 +457,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'named.sidepanel.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -442,6 +468,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'named.sidepanel/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -454,6 +481,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'devtools.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -464,6 +492,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'devtools/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -476,6 +505,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'onboarding.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -486,6 +516,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'onboarding/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -498,6 +529,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'iframe.scss'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -508,6 +540,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'iframe.css'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -520,6 +553,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'content.css'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -530,6 +564,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'overlay.content.css'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -540,6 +575,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'content/index.css'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -550,6 +586,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'overlay.content/index.css'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
])('should find entrypoint for %s', async (path, expected) => {
|
||||
@@ -737,4 +774,29 @@ describe('findEntrypoints', () => {
|
||||
expect(entrypoints).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterEntrypoints option', () => {
|
||||
it('should control entrypoints accessible', async () => {
|
||||
globMock.mockResolvedValue([
|
||||
'options/index.html',
|
||||
'popup/index.html',
|
||||
'ui.content/index.ts',
|
||||
'injected.content/index.ts',
|
||||
]);
|
||||
importEntrypointFileMock.mockResolvedValue({});
|
||||
const filterEntrypoints = ['popup', 'ui'];
|
||||
const config = fakeInternalConfig({
|
||||
root: '/',
|
||||
entrypointsDir: resolve('/src/entrypoints'),
|
||||
outDir: resolve('.output'),
|
||||
command: 'build',
|
||||
filterEntrypoints: new Set(filterEntrypoints),
|
||||
});
|
||||
|
||||
const entrypoints = await findEntrypoints(config);
|
||||
const names = entrypoints.map((item) => item.name);
|
||||
expect(names).toHaveLength(2);
|
||||
expect(names).toEqual(filterEntrypoints);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ const background: Entrypoint = {
|
||||
inputPath: '/background.ts',
|
||||
outputDir: '/.output/background',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
const contentScript: Entrypoint = {
|
||||
type: 'content-script',
|
||||
@@ -17,6 +18,7 @@ const contentScript: Entrypoint = {
|
||||
options: {
|
||||
matches: ['<all_urls>'],
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
const unlistedScript: Entrypoint = {
|
||||
type: 'unlisted-script',
|
||||
@@ -24,6 +26,7 @@ const unlistedScript: Entrypoint = {
|
||||
inputPath: '/injected.ts',
|
||||
outputDir: '/.output/injected',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
const popup: Entrypoint = {
|
||||
type: 'popup',
|
||||
@@ -31,6 +34,7 @@ const popup: Entrypoint = {
|
||||
inputPath: '/popup.html',
|
||||
outputDir: '/.output/popup',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
const unlistedPage: Entrypoint = {
|
||||
type: 'unlisted-page',
|
||||
@@ -38,6 +42,7 @@ const unlistedPage: Entrypoint = {
|
||||
inputPath: '/onboarding.html',
|
||||
outputDir: '/.output/onboarding',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
const options: Entrypoint = {
|
||||
type: 'options',
|
||||
@@ -45,6 +50,7 @@ const options: Entrypoint = {
|
||||
inputPath: '/options.html',
|
||||
outputDir: '/.output/options',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
const sandbox1: Entrypoint = {
|
||||
type: 'sandbox',
|
||||
@@ -52,6 +58,7 @@ const sandbox1: Entrypoint = {
|
||||
inputPath: '/sandbox1.html',
|
||||
outputDir: '/.output/sandbox1',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
const sandbox2: Entrypoint = {
|
||||
type: 'sandbox',
|
||||
@@ -59,6 +66,7 @@ const sandbox2: Entrypoint = {
|
||||
inputPath: '/sandbox2.html',
|
||||
outputDir: '/.output/sandbox2',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
const unlistedStyle: Entrypoint = {
|
||||
type: 'unlisted-style',
|
||||
@@ -66,6 +74,7 @@ const unlistedStyle: Entrypoint = {
|
||||
inputPath: '/injected.scss',
|
||||
outputDir: '/.output',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
const contentScriptStyle: Entrypoint = {
|
||||
type: 'content-script-style',
|
||||
@@ -73,6 +82,7 @@ const contentScriptStyle: Entrypoint = {
|
||||
inputPath: '/overlay.content.scss',
|
||||
outputDir: '/.output/content-scripts',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
|
||||
describe('groupEntrypoints', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { importEntrypointFile } from '~/core/utils/building';
|
||||
import { fakeInternalConfig } from '~/core/utils/testing/fake-objects';
|
||||
import { resolve } from 'node:path';
|
||||
import { unnormalizePath } from '../../paths';
|
||||
|
||||
const entrypointPath = (filename: string) =>
|
||||
resolve('src/core/utils/__tests__/test-entrypoints', filename);
|
||||
@@ -38,4 +39,15 @@ describe('importEntrypointFile', () => {
|
||||
|
||||
expect(actual).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw a custom error message when an imported variable is used before main', async () => {
|
||||
const filePath = unnormalizePath(
|
||||
'../src/core/utils/__tests__/test-entrypoints/imported-option.ts',
|
||||
);
|
||||
await expect(() =>
|
||||
importEntrypointFile(entrypointPath('imported-option.ts'), config),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: ${filePath}: Cannot use imported variable "faker" outside the main function. See https://wxt.dev/guide/entrypoints.html#side-effects]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,12 +18,15 @@ export async function buildEntrypoints(
|
||||
const steps: BuildStepOutput[] = [];
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const group = groups[i];
|
||||
const groupNames = [group]
|
||||
.flat()
|
||||
.map((e) => e.name)
|
||||
.join(pc.dim(', '));
|
||||
spinner.text = pc.dim(`[${i + 1}/${groups.length}]`) + ` ${groupNames}`;
|
||||
steps.push(await config.builder.build(group));
|
||||
const groupNames = [group].flat().map((e) => e.name);
|
||||
const groupNameColored = groupNames.join(pc.dim(', '));
|
||||
spinner.text =
|
||||
pc.dim(`[${i + 1}/${groups.length}]`) + ` ${groupNameColored}`;
|
||||
try {
|
||||
steps.push(await config.builder.build(group));
|
||||
} catch (err) {
|
||||
throw Error(`Failed to build ${groupNames.join(', ')}`, { cause: err });
|
||||
}
|
||||
}
|
||||
const publicAssets = await copyPublicDirectory(config);
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ import {
|
||||
BuildOutput,
|
||||
BuildStepOutput,
|
||||
EntrypointGroup,
|
||||
InternalConfig,
|
||||
OutputAsset,
|
||||
OutputFile,
|
||||
} from '~/types';
|
||||
import { every } from '~/core/utils/arrays';
|
||||
import { every, some } from '~/core/utils/arrays';
|
||||
import { normalizePath } from '~/core/utils/paths';
|
||||
|
||||
/**
|
||||
@@ -29,10 +30,21 @@ import { normalizePath } from '~/core/utils/paths';
|
||||
* - Config file changed (wxt.config.ts, .env, web-ext.config.ts, etc)
|
||||
*/
|
||||
export function detectDevChanges(
|
||||
changedFiles: [event: string, path: string][],
|
||||
currentOutput: BuildOutput | undefined,
|
||||
config: InternalConfig,
|
||||
changedFiles: string[],
|
||||
currentOutput: BuildOutput,
|
||||
): DevModeChange {
|
||||
if (currentOutput == null) return { type: 'no-change' };
|
||||
const isConfigChange = some(
|
||||
changedFiles,
|
||||
(file) => file === config.userConfigMetadata.configFile,
|
||||
);
|
||||
if (isConfigChange) return { type: 'full-restart' };
|
||||
|
||||
const isRunnerChange = some(
|
||||
changedFiles,
|
||||
(file) => file === config.runnerConfig.configFile,
|
||||
);
|
||||
if (isRunnerChange) return { type: 'browser-restart' };
|
||||
|
||||
const changedSteps = new Set(
|
||||
changedFiles.flatMap((changedFile) =>
|
||||
@@ -69,7 +81,7 @@ export function detectDevChanges(
|
||||
|
||||
const isOnlyHtmlChanges =
|
||||
changedFiles.length > 0 &&
|
||||
every(changedFiles, ([_, file]) => file.endsWith('.html'));
|
||||
every(changedFiles, (file) => file.endsWith('.html'));
|
||||
if (isOnlyHtmlChanges) {
|
||||
return {
|
||||
type: 'html-reload',
|
||||
@@ -104,11 +116,11 @@ export function detectDevChanges(
|
||||
* For a single change, return all the step of the build output that were effected by it.
|
||||
*/
|
||||
function findEffectedSteps(
|
||||
changedFile: [event: string, path: string],
|
||||
changedFile: string,
|
||||
currentOutput: BuildOutput,
|
||||
): DetectedChange[] {
|
||||
const changes: DetectedChange[] = [];
|
||||
const changedPath = normalizePath(changedFile[1]);
|
||||
const changedPath = normalizePath(changedFile);
|
||||
|
||||
const isChunkEffected = (chunk: OutputFile): boolean =>
|
||||
// If it's an HTML file with the same path, is is effected because HTML files need to be pre-rendered
|
||||
@@ -139,8 +151,9 @@ export type DevModeChange =
|
||||
| NoChange
|
||||
| HtmlReload
|
||||
| ExtensionReload
|
||||
| ContentScriptReload;
|
||||
// | BrowserRestart
|
||||
| ContentScriptReload
|
||||
| FullRestart
|
||||
| BrowserRestart;
|
||||
|
||||
interface NoChange {
|
||||
type: 'no-change';
|
||||
@@ -157,6 +170,14 @@ interface RebuildChange {
|
||||
cachedOutput: BuildOutput;
|
||||
}
|
||||
|
||||
interface FullRestart {
|
||||
type: 'full-restart';
|
||||
}
|
||||
|
||||
interface BrowserRestart {
|
||||
type: 'browser-restart';
|
||||
}
|
||||
|
||||
interface HtmlReload extends RebuildChange {
|
||||
type: 'html-reload';
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '~/core/utils/entrypoints';
|
||||
import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '~/core/utils/constants';
|
||||
import { CSS_EXTENSIONS_PATTERN } from '~/core/utils/paths';
|
||||
import pc from 'picocolors';
|
||||
|
||||
/**
|
||||
* Return entrypoints and their configuration by looking through the project's files.
|
||||
@@ -48,7 +49,14 @@ export async function findEntrypoints(
|
||||
);
|
||||
if (matchingGlob) {
|
||||
const type = PATH_GLOB_TO_TYPE_MAP[matchingGlob];
|
||||
results.push({ name, inputPath, type });
|
||||
results.push({
|
||||
name,
|
||||
inputPath,
|
||||
type,
|
||||
skipped:
|
||||
config.filterEntrypoints != null &&
|
||||
!config.filterEntrypoints.has(name),
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}, []);
|
||||
@@ -106,11 +114,22 @@ export async function findEntrypoints(
|
||||
inputPath: VIRTUAL_NOOP_BACKGROUND_MODULE_ID,
|
||||
name: 'background',
|
||||
type: 'background',
|
||||
skipped: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
config.logger.debug('All entrypoints:', entrypoints);
|
||||
const skippedEntrypointNames = entrypointInfos
|
||||
.filter((item) => item.skipped)
|
||||
.map((item) => item.name);
|
||||
if (skippedEntrypointNames.length) {
|
||||
config.logger.warn(
|
||||
`Filter excluded the following entrypoints:\n${skippedEntrypointNames
|
||||
.map((item) => `${pc.dim('-')} ${pc.cyan(item)}`)
|
||||
.join('\n')}`,
|
||||
);
|
||||
}
|
||||
const targetEntrypoints = entrypoints.filter((entry) => {
|
||||
const { include, exclude } = entry.options;
|
||||
if (include?.length && exclude?.length) {
|
||||
@@ -125,6 +144,9 @@ export async function findEntrypoints(
|
||||
if (include?.length && !exclude?.length) {
|
||||
return include.includes(config.browser);
|
||||
}
|
||||
if (skippedEntrypointNames.includes(entry.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
@@ -136,6 +158,10 @@ interface EntrypointInfo {
|
||||
name: string;
|
||||
inputPath: string;
|
||||
type: Entrypoint['type'];
|
||||
/**
|
||||
* @default false
|
||||
*/
|
||||
skipped: boolean;
|
||||
}
|
||||
|
||||
function preventDuplicateEntrypointNames(
|
||||
@@ -202,7 +228,7 @@ function getHtmlBaseOptions(document: Document): BaseEntrypointOptions {
|
||||
*/
|
||||
async function getPopupEntrypoint(
|
||||
config: InternalConfig,
|
||||
{ inputPath, name }: EntrypointInfo,
|
||||
{ inputPath, name, skipped }: EntrypointInfo,
|
||||
): Promise<PopupEntrypoint> {
|
||||
const content = await fs.readFile(inputPath, 'utf-8');
|
||||
const { document } = parseHTML(content);
|
||||
@@ -247,6 +273,7 @@ async function getPopupEntrypoint(
|
||||
options,
|
||||
inputPath,
|
||||
outputDir: config.outDir,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,7 +283,7 @@ async function getPopupEntrypoint(
|
||||
*/
|
||||
async function getOptionsEntrypoint(
|
||||
config: InternalConfig,
|
||||
{ inputPath, name }: EntrypointInfo,
|
||||
{ inputPath, name, skipped }: EntrypointInfo,
|
||||
): Promise<OptionsEntrypoint> {
|
||||
const content = await fs.readFile(inputPath, 'utf-8');
|
||||
const { document } = parseHTML(content);
|
||||
@@ -290,6 +317,7 @@ async function getOptionsEntrypoint(
|
||||
options,
|
||||
inputPath,
|
||||
outputDir: config.outDir,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -299,7 +327,7 @@ async function getOptionsEntrypoint(
|
||||
*/
|
||||
async function getUnlistedPageEntrypoint(
|
||||
config: InternalConfig,
|
||||
{ inputPath, name }: EntrypointInfo,
|
||||
{ inputPath, name, skipped }: EntrypointInfo,
|
||||
): Promise<GenericEntrypoint> {
|
||||
const content = await fs.readFile(inputPath, 'utf-8');
|
||||
const { document } = parseHTML(content);
|
||||
@@ -310,6 +338,7 @@ async function getUnlistedPageEntrypoint(
|
||||
inputPath,
|
||||
outputDir: config.outDir,
|
||||
options: getHtmlBaseOptions(document),
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -319,7 +348,7 @@ async function getUnlistedPageEntrypoint(
|
||||
*/
|
||||
async function getUnlistedScriptEntrypoint(
|
||||
config: InternalConfig,
|
||||
{ inputPath, name }: EntrypointInfo,
|
||||
{ inputPath, name, skipped }: EntrypointInfo,
|
||||
): Promise<GenericEntrypoint> {
|
||||
const defaultExport = await importEntrypointFile<UnlistedScriptDefinition>(
|
||||
inputPath,
|
||||
@@ -338,6 +367,7 @@ async function getUnlistedScriptEntrypoint(
|
||||
inputPath,
|
||||
outputDir: config.outDir,
|
||||
options,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -346,7 +376,7 @@ async function getUnlistedScriptEntrypoint(
|
||||
*/
|
||||
async function getBackgroundEntrypoint(
|
||||
config: InternalConfig,
|
||||
{ inputPath, name }: EntrypointInfo,
|
||||
{ inputPath, name, skipped }: EntrypointInfo,
|
||||
): Promise<BackgroundEntrypoint> {
|
||||
let options: Omit<BackgroundDefinition, 'main'> = {};
|
||||
if (inputPath !== VIRTUAL_NOOP_BACKGROUND_MODULE_ID) {
|
||||
@@ -372,6 +402,7 @@ async function getBackgroundEntrypoint(
|
||||
type: resolvePerBrowserOption(options.type, config.browser),
|
||||
persistent: resolvePerBrowserOption(options.persistent, config.browser),
|
||||
},
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -380,7 +411,7 @@ async function getBackgroundEntrypoint(
|
||||
*/
|
||||
async function getContentScriptEntrypoint(
|
||||
config: InternalConfig,
|
||||
{ inputPath, name }: EntrypointInfo,
|
||||
{ inputPath, name, skipped }: EntrypointInfo,
|
||||
): Promise<ContentScriptEntrypoint> {
|
||||
const { main: _, ...options } =
|
||||
await importEntrypointFile<ContentScriptDefinition>(inputPath, config);
|
||||
@@ -395,6 +426,7 @@ async function getContentScriptEntrypoint(
|
||||
inputPath,
|
||||
outputDir: resolve(config.outDir, CONTENT_SCRIPT_OUT_DIR),
|
||||
options,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -79,8 +79,10 @@ import "wxt/browser";
|
||||
declare module "wxt/browser" {
|
||||
export type PublicPath =
|
||||
{{ union }}
|
||||
type HtmlPublicPath = Extract<PublicPath, \`\${string}.html\`>
|
||||
export interface WxtRuntime extends Runtime.Static {
|
||||
getURL(path: PublicPath): string;
|
||||
getURL(path: \`\${HtmlPublicPath}\${string}\`): string;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -134,7 +136,7 @@ declare module "wxt/browser" {
|
||||
|
||||
const overrides = messages.map((message) => {
|
||||
return ` /**
|
||||
* ${message.description ?? 'No message description.'}
|
||||
* ${message.description || 'No message description.'}
|
||||
*
|
||||
* "${message.message}"
|
||||
*/
|
||||
@@ -162,8 +164,11 @@ async function writeGlobalsDeclarationFile(
|
||||
[
|
||||
'// Generated by wxt',
|
||||
'export {}',
|
||||
'declare global {',
|
||||
...globals.map((global) => ` const ${global.name}: ${global.type};`),
|
||||
'interface ImportMetaEnv {',
|
||||
...globals.map((global) => ` readonly ${global.name}: ${global.type};`),
|
||||
'}',
|
||||
'interface ImportMeta {',
|
||||
' readonly env: ImportMetaEnv',
|
||||
'}',
|
||||
].join('\n') + '\n',
|
||||
);
|
||||
@@ -180,7 +185,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))}" />`,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import path from 'node:path';
|
||||
import { createFsCache } from '~/core/utils/cache';
|
||||
import consola, { LogLevels } from 'consola';
|
||||
import { craeteViteBuilder } from '~/core/builders/vite';
|
||||
import { createViteBuilder } from '~/core/builders/vite';
|
||||
import defu from 'defu';
|
||||
import { NullablyRequired } from '../types';
|
||||
|
||||
@@ -72,10 +72,14 @@ export async function getInternalConfig(
|
||||
srcDir,
|
||||
mergedConfig.entrypointsDir ?? 'entrypoints',
|
||||
);
|
||||
const filterEntrypoints = !!mergedConfig.filterEntrypoints?.length
|
||||
? new Set(mergedConfig.filterEntrypoints)
|
||||
: undefined;
|
||||
const publicDir = path.resolve(srcDir, mergedConfig.publicDir ?? 'public');
|
||||
const typesDir = path.resolve(wxtDir, 'types');
|
||||
const outBaseDir = path.resolve(root, mergedConfig.outDir ?? '.output');
|
||||
const outDir = path.resolve(outBaseDir, `${browser}-mv${manifestVersion}`);
|
||||
const reloadCommand = mergedConfig.dev?.reloadCommand ?? 'Alt+R';
|
||||
|
||||
const runnerConfig = await loadConfig<ExtensionRunnerConfig>({
|
||||
name: 'web-ext',
|
||||
@@ -101,6 +105,7 @@ export async function getInternalConfig(
|
||||
command,
|
||||
debug,
|
||||
entrypointsDir,
|
||||
filterEntrypoints,
|
||||
env,
|
||||
fsCache: createFsCache(wxtDir),
|
||||
imports: mergedConfig.imports ?? {},
|
||||
@@ -132,9 +137,12 @@ export async function getInternalConfig(
|
||||
mergedConfig.experimental?.includeBrowserPolyfill ?? true,
|
||||
},
|
||||
server,
|
||||
dev: {
|
||||
reloadCommand,
|
||||
},
|
||||
};
|
||||
|
||||
const builder = await craeteViteBuilder(
|
||||
const builder = await createViteBuilder(
|
||||
inlineConfig,
|
||||
userConfig,
|
||||
finalConfig,
|
||||
@@ -191,6 +199,8 @@ function mergeInlineConfig(
|
||||
configFile: inlineConfig.configFile,
|
||||
debug: inlineConfig.debug ?? userConfig.debug,
|
||||
entrypointsDir: inlineConfig.entrypointsDir ?? userConfig.entrypointsDir,
|
||||
filterEntrypoints:
|
||||
inlineConfig.filterEntrypoints ?? userConfig.filterEntrypoints,
|
||||
imports,
|
||||
logger: inlineConfig.logger ?? userConfig.logger,
|
||||
manifest,
|
||||
@@ -215,6 +225,10 @@ function mergeInlineConfig(
|
||||
},
|
||||
vite: undefined,
|
||||
transformManifest: undefined,
|
||||
dev: {
|
||||
...userConfig.dev,
|
||||
...inlineConfig.dev,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,8 +241,9 @@ function resolveInternalZipConfig(
|
||||
sourcesTemplate: '{{name}}-{{version}}-sources.zip',
|
||||
artifactTemplate: '{{name}}-{{version}}-{{browser}}.zip',
|
||||
sourcesRoot: root,
|
||||
includeSources: [],
|
||||
...mergedConfig.zip,
|
||||
ignoredSources: [
|
||||
excludeSources: [
|
||||
'**/node_modules',
|
||||
// WXT files
|
||||
'**/web-ext.config.ts',
|
||||
@@ -238,7 +253,7 @@ function resolveInternalZipConfig(
|
||||
'**/__tests__/**',
|
||||
'**/*.+(test|spec).?(c|m)+(j|t)s?(x)',
|
||||
// From user
|
||||
...(mergedConfig.zip?.ignoredSources ?? []),
|
||||
...(mergedConfig.zip?.excludeSources ?? []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import createJITI, { TransformOptions as JitiTransformOptions } from 'jiti';
|
||||
import { InternalConfig } from '~/types';
|
||||
import { createUnimport } from 'unimport';
|
||||
import fs from 'fs-extra';
|
||||
import { resolve } from 'node:path';
|
||||
import { relative, resolve } from 'node:path';
|
||||
import { getUnimportOptions } from '~/core/utils/unimport';
|
||||
import { removeProjectImportStatements } from '~/core/utils/strings';
|
||||
import { normalizePath } from '~/core/utils/paths';
|
||||
@@ -89,8 +89,18 @@ export async function importEntrypointFile<T>(
|
||||
const res = await jiti(path);
|
||||
return res.default;
|
||||
} catch (err) {
|
||||
config.logger.error(err);
|
||||
throw err;
|
||||
const filePath = relative(config.root, path);
|
||||
if (err instanceof ReferenceError) {
|
||||
// "XXX is not defined" - usually due to WXT removing imports
|
||||
const variableName = err.message.replace(' is not defined', '');
|
||||
throw Error(
|
||||
`${filePath}: Cannot use imported variable "${variableName}" outside the main function. See https://wxt.dev/guide/entrypoints.html#side-effects`,
|
||||
{ cause: err },
|
||||
);
|
||||
} else {
|
||||
config.logger.error(err);
|
||||
throw Error(`Failed to load entrypoint: ${filePath}`, { cause: err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { findEntrypoints } from './find-entrypoints';
|
||||
import { InternalConfig, BuildOutput } from '~/types';
|
||||
import { InternalConfig, BuildOutput, Entrypoint } from '~/types';
|
||||
import pc from 'picocolors';
|
||||
import fs from 'fs-extra';
|
||||
import { groupEntrypoints } from './group-entrypoints';
|
||||
@@ -8,6 +8,15 @@ import { printBuildSummary } from '~/core/utils/log';
|
||||
import glob from 'fast-glob';
|
||||
import { unnormalizePath } from '~/core/utils/paths';
|
||||
import { rebuild } from './rebuild';
|
||||
import managePath from 'manage-path';
|
||||
import { resolve, relative } from 'node:path';
|
||||
import {
|
||||
ValidationError,
|
||||
ValidationResult,
|
||||
ValidationResults,
|
||||
validateEntrypoints,
|
||||
} from '../validation';
|
||||
import consola from 'consola';
|
||||
|
||||
/**
|
||||
* Builds the extension based on an internal config. No more config discovery is performed, the
|
||||
@@ -37,8 +46,24 @@ export async function internalBuild(
|
||||
|
||||
const entrypoints = await findEntrypoints(config);
|
||||
config.logger.debug('Detected entrypoints:', entrypoints);
|
||||
|
||||
const validationResults = validateEntrypoints(entrypoints);
|
||||
if (validationResults.errorCount + validationResults.warningCount > 0) {
|
||||
printValidationResults(config, validationResults);
|
||||
}
|
||||
if (validationResults.errorCount > 0) {
|
||||
throw new ValidationError(`Entrypoint validation failed`, {
|
||||
cause: validationResults,
|
||||
});
|
||||
}
|
||||
|
||||
const groups = groupEntrypoints(entrypoints);
|
||||
const { output } = await rebuild(config, groups, undefined);
|
||||
const { output, warnings } = await rebuild(
|
||||
config,
|
||||
entrypoints,
|
||||
groups,
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Post-build
|
||||
await printBuildSummary(
|
||||
@@ -48,6 +73,10 @@ export async function internalBuild(
|
||||
config,
|
||||
);
|
||||
|
||||
for (const warning of warnings) {
|
||||
config.logger.warn(...warning);
|
||||
}
|
||||
|
||||
if (config.analysis.enabled) {
|
||||
await combineAnalysisStats(config);
|
||||
config.logger.info(
|
||||
@@ -66,6 +95,10 @@ async function combineAnalysisStats(config: InternalConfig): Promise<void> {
|
||||
});
|
||||
const absolutePaths = unixFiles.map(unnormalizePath);
|
||||
|
||||
const alterPath = managePath(process.env);
|
||||
// Add subdependency path for PNPM shamefully-hoist=false
|
||||
alterPath.push(resolve(config.root, 'node_modules/wxt/node_modules/.bin'));
|
||||
|
||||
await execaCommand(
|
||||
`rollup-plugin-visualizer ${absolutePaths.join(' ')} --template ${
|
||||
config.analysis.template
|
||||
@@ -73,3 +106,33 @@ async function combineAnalysisStats(config: InternalConfig): Promise<void> {
|
||||
{ cwd: config.root, stdio: 'inherit' },
|
||||
);
|
||||
}
|
||||
|
||||
function printValidationResults(
|
||||
config: InternalConfig,
|
||||
{ errorCount, errors, warningCount }: ValidationResults,
|
||||
) {
|
||||
(errorCount > 0 ? config.logger.error : config.logger.warn)(
|
||||
`Entrypoint validation failed: ${errorCount} error${
|
||||
errorCount === 1 ? '' : 's'
|
||||
}, ${warningCount} warning${warningCount === 1 ? '' : 's'}`,
|
||||
);
|
||||
|
||||
const cwd = process.cwd();
|
||||
const entrypointErrors = errors.reduce((map, error) => {
|
||||
const entryErrors = map.get(error.entrypoint) ?? [];
|
||||
entryErrors.push(error);
|
||||
map.set(error.entrypoint, entryErrors);
|
||||
return map;
|
||||
}, new Map<Entrypoint, ValidationResult[]>());
|
||||
|
||||
Array.from(entrypointErrors.entries()).forEach(([entrypoint, errors]) => {
|
||||
consola.log(relative(cwd, entrypoint.inputPath));
|
||||
console.log();
|
||||
errors.forEach((err) => {
|
||||
const type = err.type === 'error' ? pc.red('ERROR') : pc.yellow('WARN');
|
||||
const recieved = pc.dim(`(recieved: ${JSON.stringify(err.value)})`);
|
||||
consola.log(` - ${type} ${err.message} ${recieved}`);
|
||||
});
|
||||
console.log();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { Manifest } from '~/browser';
|
||||
import { BuildOutput, EntrypointGroup, InternalConfig } from '~/types';
|
||||
import { findEntrypoints } from './find-entrypoints';
|
||||
import {
|
||||
BuildOutput,
|
||||
Entrypoint,
|
||||
EntrypointGroup,
|
||||
InternalConfig,
|
||||
} from '~/types';
|
||||
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
|
||||
@@ -14,20 +18,30 @@ import { generateMainfest, writeManifest } from '~/core/utils/manifest';
|
||||
* 2. Build the `entrypointGroups` (and copies public files)
|
||||
* 3. Generate the latest manifest for all entrypoints
|
||||
* 4. Write the new manifest to the file system
|
||||
*
|
||||
* @param config Internal config containing all the project information.
|
||||
* @param allEntrypoints List of entrypoints used to generate the types inside .wxt directory.
|
||||
* @param entrypointGroups The list of entrypoint groups to build.
|
||||
* @param existingOutput The previous output to combine the rebuild results into. An emptry array if
|
||||
* this is the first build.
|
||||
*/
|
||||
export async function rebuild(
|
||||
config: InternalConfig,
|
||||
allEntrypoints: Entrypoint[],
|
||||
entrypointGroups: EntrypointGroup[],
|
||||
existingOutput: Omit<BuildOutput, 'manifest'> = {
|
||||
steps: [],
|
||||
publicAssets: [],
|
||||
},
|
||||
): Promise<{ output: BuildOutput; manifest: Manifest.WebExtensionManifest }> {
|
||||
): Promise<{
|
||||
output: BuildOutput;
|
||||
manifest: Manifest.WebExtensionManifest;
|
||||
warnings: any[][];
|
||||
}> {
|
||||
const { default: ora } = await import('ora');
|
||||
const spinner = ora(`Preparing...`).start();
|
||||
|
||||
// Update types directory with new files and types
|
||||
const allEntrypoints = await findEntrypoints(config);
|
||||
await generateTypesDir(allEntrypoints, config).catch((err) => {
|
||||
config.logger.warn('Failed to update .wxt directory:', err);
|
||||
// Throw the error if doing a regular build, don't for dev mode.
|
||||
@@ -41,11 +55,8 @@ export async function rebuild(
|
||||
publicAssets: [...existingOutput.publicAssets, ...newOutput.publicAssets],
|
||||
};
|
||||
|
||||
const newManifest = await generateMainfest(
|
||||
allEntrypoints,
|
||||
mergedOutput,
|
||||
config,
|
||||
);
|
||||
const { manifest: newManifest, warnings: manifestWarnings } =
|
||||
await generateManifest(allEntrypoints, mergedOutput, config);
|
||||
const finalOutput: BuildOutput = {
|
||||
manifest: newManifest,
|
||||
...newOutput,
|
||||
@@ -67,5 +78,6 @@ export async function rebuild(
|
||||
],
|
||||
},
|
||||
manifest: newManifest,
|
||||
warnings: manifestWarnings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,42 +5,42 @@ export function getGlobals(
|
||||
): Array<{ name: string; value: any; type: string }> {
|
||||
return [
|
||||
{
|
||||
name: surroundInUnderscore('MANIFEST_VERSION'),
|
||||
name: 'MANIFEST_VERSION',
|
||||
value: config.manifestVersion,
|
||||
type: `2 | 3`,
|
||||
},
|
||||
{
|
||||
name: surroundInUnderscore('BROWSER'),
|
||||
name: 'BROWSER',
|
||||
value: config.browser,
|
||||
type: `string`,
|
||||
},
|
||||
{
|
||||
name: surroundInUnderscore('IS_CHROME'),
|
||||
name: 'CHROME',
|
||||
value: config.browser === 'chrome',
|
||||
type: `boolean`,
|
||||
},
|
||||
{
|
||||
name: surroundInUnderscore('IS_FIREFOX'),
|
||||
name: 'FIREFOX',
|
||||
value: config.browser === 'firefox',
|
||||
type: `boolean`,
|
||||
},
|
||||
{
|
||||
name: surroundInUnderscore('IS_SAFARI'),
|
||||
name: 'SAFARI',
|
||||
value: config.browser === 'safari',
|
||||
type: `boolean`,
|
||||
},
|
||||
{
|
||||
name: surroundInUnderscore('IS_EDGE'),
|
||||
name: 'EDGE',
|
||||
value: config.browser === 'edge',
|
||||
type: `boolean`,
|
||||
},
|
||||
{
|
||||
name: surroundInUnderscore('IS_OPERA'),
|
||||
name: 'OPERA',
|
||||
value: config.browser === 'opera',
|
||||
type: `boolean`,
|
||||
},
|
||||
{
|
||||
name: surroundInUnderscore('COMMAND'),
|
||||
name: 'COMMAND',
|
||||
value: config.command,
|
||||
type: `"build" | "serve"`,
|
||||
},
|
||||
@@ -50,17 +50,9 @@ export function getGlobals(
|
||||
export function getEntrypointGlobals(entrypointName: string) {
|
||||
return [
|
||||
{
|
||||
name: surroundInUnderscore('ENTRYPOINT'),
|
||||
name: 'ENTRYPOINT',
|
||||
value: entrypointName,
|
||||
type: `string`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Don't hardcode the complete name so that the string litterals in this file aren't replaced during
|
||||
* tests (which causes syntax errors), only during builds.
|
||||
*/
|
||||
function surroundInUnderscore(name: string): string {
|
||||
return `__${name}__`;
|
||||
}
|
||||
|
||||
+57
-11
@@ -50,26 +50,29 @@ 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> {
|
||||
): Promise<{ manifest: Manifest.WebExtensionManifest; warnings: any[][] }> {
|
||||
const warnings: any[][] = [];
|
||||
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),
|
||||
};
|
||||
@@ -80,6 +83,31 @@ export async function generateMainfest(
|
||||
baseManifest,
|
||||
) as Manifest.WebExtensionManifest;
|
||||
|
||||
// Add reload command in dev mode
|
||||
if (config.command === 'serve' && config.dev.reloadCommand) {
|
||||
if (manifest.commands && Object.keys(manifest.commands).length >= 4) {
|
||||
warnings.push([
|
||||
"Extension already has 4 registered commands, WXT's reload command is disabled",
|
||||
]);
|
||||
} else {
|
||||
manifest.commands ??= {};
|
||||
manifest.commands['wxt:reload-extension'] = {
|
||||
description: 'Reload the extension during development',
|
||||
suggested_key: {
|
||||
default: config.dev.reloadCommand,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -97,7 +125,10 @@ export async function generateMainfest(
|
||||
);
|
||||
}
|
||||
|
||||
return finalManifest;
|
||||
return {
|
||||
manifest: finalManifest,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -483,7 +514,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 +561,15 @@ function addHostPermission(
|
||||
if (manifest.host_permissions.includes(hostPermission)) return;
|
||||
manifest.host_permissions.push(hostPermission);
|
||||
}
|
||||
|
||||
/**
|
||||
* - "<all_urls>" → "<all_urls>"
|
||||
* - "*://play.google.com/books/*" → "*://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) + '/*';
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { resolve } from 'path';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import merge from 'lodash.merge';
|
||||
import type { Manifest } from '~/browser';
|
||||
import { Commands, 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',
|
||||
@@ -46,7 +59,7 @@ export const fakeContentScriptEntrypoint =
|
||||
name: faker.string.alpha(),
|
||||
options: {
|
||||
matches: [],
|
||||
matchAboutBlank: faker.helpers.arrayElement([true, false, undefined]),
|
||||
matchAboutBlank: faker.datatype.boolean(),
|
||||
matchOriginAsFallback: faker.helpers.arrayElement([
|
||||
true,
|
||||
false,
|
||||
@@ -60,18 +73,20 @@ export const fakeContentScriptEntrypoint =
|
||||
]),
|
||||
},
|
||||
outputDir: fakeDir('.output'),
|
||||
skipped: faker.datatype.boolean(),
|
||||
}));
|
||||
|
||||
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]),
|
||||
persistent: faker.datatype.boolean(),
|
||||
type: faker.helpers.maybe(() => 'module'),
|
||||
},
|
||||
outputDir: fakeDir('.output'),
|
||||
skipped: faker.datatype.boolean(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -82,26 +97,28 @@ export const fakeUnlistedScriptEntrypoint =
|
||||
name: faker.string.alpha(),
|
||||
outputDir: fakeDir('.output'),
|
||||
options: {},
|
||||
skipped: faker.datatype.boolean(),
|
||||
}));
|
||||
|
||||
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]),
|
||||
chromeStyle: faker.helpers.arrayElement([true, false, undefined]),
|
||||
openInTab: faker.helpers.arrayElement([true, false, undefined]),
|
||||
browserStyle: faker.datatype.boolean(),
|
||||
chromeStyle: faker.datatype.boolean(),
|
||||
openInTab: faker.datatype.boolean(),
|
||||
},
|
||||
skipped: faker.datatype.boolean(),
|
||||
}),
|
||||
);
|
||||
|
||||
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([
|
||||
@@ -121,6 +138,7 @@ export const fakePopupEntrypoint = fakeObjectCreator<PopupEntrypoint>(() => ({
|
||||
undefined,
|
||||
]),
|
||||
},
|
||||
skipped: faker.datatype.boolean(),
|
||||
}));
|
||||
|
||||
export const fakeGenericEntrypoint = fakeObjectCreator<GenericEntrypoint>(
|
||||
@@ -139,6 +157,7 @@ export const fakeGenericEntrypoint = fakeObjectCreator<GenericEntrypoint>(
|
||||
name: faker.string.alpha(),
|
||||
outputDir: fakeDir('.output'),
|
||||
options: {},
|
||||
skipped: faker.datatype.boolean(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -165,6 +184,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 +211,7 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
|
||||
fsCache: mock<FsCache>(),
|
||||
imports: {},
|
||||
logger: mock(),
|
||||
manifest: fakeManifest(),
|
||||
manifest: fakeUserManifest(),
|
||||
manifestVersion,
|
||||
mode,
|
||||
outBaseDir: fakeDir(),
|
||||
@@ -208,7 +232,8 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
|
||||
},
|
||||
zip: {
|
||||
artifactTemplate: '{{name}}-{{version}}.zip',
|
||||
ignoredSources: [],
|
||||
includeSources: [],
|
||||
excludeSources: [],
|
||||
sourcesRoot: fakeDir(),
|
||||
sourcesTemplate: '{{name}}-sources.zip',
|
||||
name: faker.person.firstName().toLowerCase(),
|
||||
@@ -220,5 +245,27 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
|
||||
includeBrowserPolyfill: true,
|
||||
},
|
||||
builder: mock(),
|
||||
dev: {
|
||||
reloadCommand: 'Alt+R',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const fakeBuildOutput = fakeObjectCreator<BuildOutput>(() => ({
|
||||
manifest: fakeManifest(),
|
||||
publicAssets: fakeArray(fakeOutputAsset),
|
||||
steps: fakeArray(fakeBuildStepOutput),
|
||||
}));
|
||||
|
||||
export const fakeBuildStepOutput = fakeObjectCreator<BuildStepOutput>(() => ({
|
||||
chunks: fakeArray(fakeOutputChunk),
|
||||
entrypoints: fakeArray(fakeEntrypoint),
|
||||
}));
|
||||
|
||||
export const fakeManifestCommand = fakeObjectCreator<Commands.Command>(() => ({
|
||||
description: faker.string.sample(),
|
||||
shortcut: `${faker.helpers.arrayElement(['ctrl', 'alt'])}+${faker.number.int({
|
||||
min: 0,
|
||||
max: 9,
|
||||
})}`,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ContentScriptEntrypoint, Entrypoint } from '~/types';
|
||||
|
||||
export function validateEntrypoints(
|
||||
entrypoints: Entrypoint[],
|
||||
): ValidationResults {
|
||||
const errors = entrypoints.flatMap((entrypoint) => {
|
||||
switch (entrypoint.type) {
|
||||
case 'content-script':
|
||||
return validateContentScriptEntrypoint(entrypoint);
|
||||
default:
|
||||
return validateBaseEntrypoint(entrypoint);
|
||||
}
|
||||
});
|
||||
|
||||
let errorCount = 0;
|
||||
let warningCount = 0;
|
||||
for (const err of errors) {
|
||||
if (err.type === 'warning') warningCount++;
|
||||
else errorCount++;
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
errorCount,
|
||||
warningCount,
|
||||
};
|
||||
}
|
||||
|
||||
function validateContentScriptEntrypoint(
|
||||
definition: ContentScriptEntrypoint,
|
||||
): ValidationResult[] {
|
||||
const errors = validateBaseEntrypoint(definition);
|
||||
if (definition.options.matches == null) {
|
||||
errors.push({
|
||||
type: 'error',
|
||||
message: '`matches` is required',
|
||||
value: definition.options.matches,
|
||||
entrypoint: definition,
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateBaseEntrypoint(definition: Entrypoint): ValidationResult[] {
|
||||
const errors: ValidationResult[] = [];
|
||||
|
||||
if (
|
||||
definition.options.exclude != null &&
|
||||
!Array.isArray(definition.options.exclude)
|
||||
) {
|
||||
errors.push({
|
||||
type: 'error',
|
||||
message: '`exclude` must be an array of browser names',
|
||||
value: definition.options.exclude,
|
||||
entrypoint: definition,
|
||||
});
|
||||
}
|
||||
if (
|
||||
definition.options.include != null &&
|
||||
!Array.isArray(definition.options.include)
|
||||
) {
|
||||
errors.push({
|
||||
type: 'error',
|
||||
message: '`include` must be an array of browser names',
|
||||
value: definition.options.include,
|
||||
entrypoint: definition,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
type: 'warning' | 'error';
|
||||
message: string;
|
||||
entrypoint: Entrypoint;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface ValidationResults {
|
||||
errors: ValidationResult[];
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
}
|
||||
|
||||
export class ValidationError extends Error {}
|
||||
+8
-3
@@ -62,10 +62,15 @@ export async function zip(config?: InlineConfig): Promise<string[]> {
|
||||
saveTo: sourcesZipPath,
|
||||
filter(path) {
|
||||
const relativePath = relative(internalConfig.zip.sourcesRoot, path);
|
||||
const matchedPattern = internalConfig.zip.ignoredSources.find(
|
||||
(pattern) => minimatch(relativePath, pattern),
|
||||
|
||||
return (
|
||||
internalConfig.zip.includeSources.some((pattern) =>
|
||||
minimatch(relativePath, pattern),
|
||||
) ||
|
||||
!internalConfig.zip.excludeSources.some((pattern) =>
|
||||
minimatch(relativePath, pattern),
|
||||
)
|
||||
);
|
||||
return matchedPattern == null;
|
||||
},
|
||||
});
|
||||
zipFiles.push(sourcesZipPath);
|
||||
|
||||
+11
-1
@@ -308,10 +308,20 @@ function createDriver(
|
||||
storageArea: 'local' | 'session' | 'sync' | 'managed',
|
||||
): WxtStorageDriver {
|
||||
const getStorageArea = () => {
|
||||
if (browser.storage == null)
|
||||
if (browser.runtime == null) {
|
||||
throw Error(
|
||||
[
|
||||
"'wxt/storage' must be loaded in a web extension environment",
|
||||
'\n - If thrown during a build, see https://github.com/wxt-dev/wxt/issues/371',
|
||||
" - If thrown during tests, mock 'wxt/browser' correctly. See https://wxt.dev/guide/testing.html\n",
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
if (browser.storage == null) {
|
||||
throw Error(
|
||||
"You must add the 'storage' permission to your manifest to use 'wxt/storage'",
|
||||
);
|
||||
}
|
||||
|
||||
return browser.storage[storageArea];
|
||||
};
|
||||
|
||||
+58
-4
@@ -31,6 +31,12 @@ export interface InlineConfig {
|
||||
* @default "${config.srcDir}/entrypoints"
|
||||
*/
|
||||
entrypointsDir?: string;
|
||||
/**
|
||||
* A list of entrypoint names (`"popup"`, `"options"`, etc.) to build. Will speed up the build if
|
||||
* your extension has lots of entrypoints, and you don't need to build all of them to develop a
|
||||
* feature.
|
||||
*/
|
||||
filterEntrypoints?: string[];
|
||||
/**
|
||||
* Output directory that stored build folders and ZIPs.
|
||||
*
|
||||
@@ -137,9 +143,22 @@ export interface InlineConfig {
|
||||
* @default config.root
|
||||
*/
|
||||
sourcesRoot?: string;
|
||||
/**
|
||||
* [Minimatch](https://www.npmjs.com/package/minimatch) patterns of files to include when
|
||||
* creating a ZIP of all your source code for Firefox. Patterns are relative to your
|
||||
* `config.zip.sourcesRoot`.
|
||||
*
|
||||
* This setting overrides `excludeSources`. So if a file matches both lists, it is included in the ZIP.
|
||||
*
|
||||
* @example
|
||||
* [
|
||||
* "coverage", // Ignore the coverage directory in the `sourcesRoot`
|
||||
* ]
|
||||
*/
|
||||
includeSources?: string[];
|
||||
/**
|
||||
* [Minimatch](https://www.npmjs.com/package/minimatch) patterns of files to exclude when
|
||||
* creating a ZIP of all your source code for Firfox. Patterns are relative to your
|
||||
* creating a ZIP of all your source code for Firefox. Patterns are relative to your
|
||||
* `config.zip.sourcesRoot`.
|
||||
*
|
||||
* Hidden files, node_modules, and tests are ignored by default.
|
||||
@@ -149,7 +168,7 @@ export interface InlineConfig {
|
||||
* "coverage", // Ignore the coverage directory in the `sourcesRoot`
|
||||
* ]
|
||||
*/
|
||||
ignoredSources?: string[];
|
||||
excludeSources?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -223,6 +242,24 @@ export interface InlineConfig {
|
||||
*/
|
||||
includeBrowserPolyfill?: boolean;
|
||||
};
|
||||
/**
|
||||
* Config effecting dev mode only.
|
||||
*/
|
||||
dev?: {
|
||||
/**
|
||||
* Controls whether a custom keyboard shortcut command, `Alt+R`, is added during dev mode to
|
||||
* quickly reload the extension.
|
||||
*
|
||||
* If false, the shortcut is not added during development.
|
||||
*
|
||||
* If set to a custom string, you can override the key combo used. See
|
||||
* [Chrome's command docs](https://developer.chrome.com/docs/extensions/reference/api/commands)
|
||||
* for available options.
|
||||
*
|
||||
* @default "Alt+R"
|
||||
*/
|
||||
reloadCommand?: string | false;
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Extract to @wxt/vite-builder and use module augmentation to include the vite field
|
||||
@@ -284,16 +321,24 @@ export interface BuildStepOutput {
|
||||
}
|
||||
|
||||
export interface WxtDevServer
|
||||
extends Omit<WxtBuilderServer, 'listen'>,
|
||||
extends Omit<WxtBuilderServer, 'listen' | 'close'>,
|
||||
ServerInfo {
|
||||
/**
|
||||
* Stores the current build output of the server.
|
||||
*/
|
||||
currentOutput: BuildOutput;
|
||||
currentOutput: BuildOutput | undefined;
|
||||
/**
|
||||
* Start the server.
|
||||
*/
|
||||
start(): Promise<void>;
|
||||
/**
|
||||
* Stop the server.
|
||||
*/
|
||||
stop(): Promise<void>;
|
||||
/**
|
||||
* Close the browser, stop the server, rebuild the entire extension, and start the server again.
|
||||
*/
|
||||
restart(): Promise<void>;
|
||||
/**
|
||||
* Transform the HTML for dev mode.
|
||||
*/
|
||||
@@ -326,6 +371,10 @@ export interface WxtDevServer
|
||||
reloadContentScript: (
|
||||
contentScript: Omit<Scripting.RegisteredContentScript, 'id'>,
|
||||
) => void;
|
||||
/**
|
||||
* Grab the latest runner config and restart the browser.
|
||||
*/
|
||||
restartBrowser: () => void;
|
||||
}
|
||||
|
||||
export type TargetBrowser = string;
|
||||
@@ -377,6 +426,7 @@ export interface BaseEntrypoint {
|
||||
*/
|
||||
outputDir: string;
|
||||
options: BaseEntrypointOptions;
|
||||
skipped: boolean;
|
||||
}
|
||||
|
||||
export interface GenericEntrypoint extends BaseEntrypoint {
|
||||
@@ -667,6 +717,10 @@ export interface WxtBuilderServer {
|
||||
* Start the server.
|
||||
*/
|
||||
listen(): Promise<void>;
|
||||
/**
|
||||
* Stop the server.
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
/**
|
||||
* Transform the HTML for dev mode.
|
||||
*/
|
||||
|
||||
Vendored
+7
-4
@@ -1,7 +1,10 @@
|
||||
// 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;
|
||||
|
||||
// Globals defined by the vite-plugins/devServerGlobals.ts and utils/globals.ts
|
||||
interface ImportMetaEnv {
|
||||
readonly COMMAND: 'build' | 'serve';
|
||||
readonly MANIFEST_VERSION: 2 | 3;
|
||||
readonly ENTRYPOINT: string;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface InternalConfig {
|
||||
wxtDir: string;
|
||||
typesDir: string;
|
||||
entrypointsDir: string;
|
||||
filterEntrypoints?: Set<string>;
|
||||
outBaseDir: string;
|
||||
outDir: string;
|
||||
debug: boolean;
|
||||
@@ -39,7 +40,8 @@ export interface InternalConfig {
|
||||
name?: string;
|
||||
artifactTemplate: string;
|
||||
sourcesTemplate: string;
|
||||
ignoredSources: string[];
|
||||
includeSources: string[];
|
||||
excludeSources: string[];
|
||||
sourcesRoot: string;
|
||||
};
|
||||
transformManifest: (manifest: Manifest.WebExtensionManifest) => void;
|
||||
@@ -56,6 +58,9 @@ export interface InternalConfig {
|
||||
includeBrowserPolyfill: boolean;
|
||||
};
|
||||
builder: WxtBuilder;
|
||||
dev: {
|
||||
reloadCommand: string | false;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FsCache {
|
||||
|
||||
Vendored
+4
@@ -50,6 +50,10 @@ declare module 'web-ext-run/util/logger' {
|
||||
export const consoleStream: IConsoleStream;
|
||||
}
|
||||
|
||||
declare module 'manage-path' {
|
||||
export default function managePath(env: object): string[];
|
||||
}
|
||||
|
||||
declare module 'wxt/browser' {
|
||||
// Overridden when types are generated per project
|
||||
export type PublicPath = string;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { browser } from 'wxt/browser';
|
||||
import { keepServiceWorkerAlive } from './utils/keep-service-worker-alive';
|
||||
import { reloadContentScript } from './utils/reload-content-scripts';
|
||||
|
||||
if (__COMMAND__ === 'serve') {
|
||||
if (import.meta.env.COMMAND === 'serve') {
|
||||
try {
|
||||
const ws = setupWebSocket((message) => {
|
||||
if (message.event === 'wxt:reload-extension') browser.runtime.reload();
|
||||
@@ -13,7 +13,7 @@ if (__COMMAND__ === 'serve') {
|
||||
reloadContentScript(message.data);
|
||||
});
|
||||
|
||||
if (__MANIFEST_VERSION__ === 3) {
|
||||
if (import.meta.env.MANIFEST_VERSION === 3) {
|
||||
// Tell the server the background script is loaded and ready to go
|
||||
ws.addEventListener('open', () => {
|
||||
const msg = { type: 'custom', event: 'wxt:background-initialized' };
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,12 +5,12 @@ import { ContentScriptContext } from 'wxt/client';
|
||||
(async () => {
|
||||
try {
|
||||
const { main, ...options } = definition;
|
||||
const ctx = new ContentScriptContext(__ENTRYPOINT__, options);
|
||||
const ctx = new ContentScriptContext(import.meta.env.ENTRYPOINT, options);
|
||||
|
||||
await main(ctx);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`The content script "${__ENTRYPOINT__}" crashed on startup!`,
|
||||
`The content script "${import.meta.env.ENTRYPOINT}" crashed on startup!`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { logger } from '../sandbox/utils/logger';
|
||||
await main();
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`The content script "${__ENTRYPOINT__}" crashed on startup!`,
|
||||
`The content script "${import.meta.env.ENTRYPOINT}" crashed on startup!`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { logger } from '../sandbox/utils/logger';
|
||||
import { setupWebSocket } from './utils/setup-web-socket';
|
||||
|
||||
if (__COMMAND__ === 'serve') {
|
||||
if (import.meta.env.COMMAND === 'serve') {
|
||||
try {
|
||||
setupWebSocket((message) => {
|
||||
if (message.event === 'wxt:reload-page') {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { logger } from '../sandbox/utils/logger';
|
||||
await definition.main();
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`The unlisted script "${__ENTRYPOINT__}" crashed on startup!`,
|
||||
`The unlisted script "${import.meta.env.ENTRYPOINT}" crashed on startup!`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -19,10 +19,10 @@
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"@types/react": "^18.2.46",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.3.2",
|
||||
"wxt": "^0.12.0"
|
||||
"typescript": "^5.3.3",
|
||||
"wxt": "^0.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "^1.7.8"
|
||||
"solid-js": "^1.8.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vite-plugin-solid": "^2.8.0",
|
||||
"wxt": "^0.12.0"
|
||||
"wxt": "^0.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@tsconfig/svelte": "^5.0.0",
|
||||
"svelte": "^4.0.5",
|
||||
"svelte-check": "^3.4.6",
|
||||
"tslib": "^2.6.0",
|
||||
"typescript": "^5.3.2",
|
||||
"wxt": "^0.12.0"
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.1",
|
||||
"@tsconfig/svelte": "^5.0.2",
|
||||
"svelte": "^4.2.8",
|
||||
"svelte-check": "^3.6.2",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.3",
|
||||
"wxt": "^0.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.2",
|
||||
"wxt": "^0.12.0"
|
||||
"typescript": "^5.3.3",
|
||||
"wxt": "^0.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.4"
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.1",
|
||||
"typescript": "^5.3.2",
|
||||
"vue-tsc": "^1.8.25",
|
||||
"wxt": "^0.12.0"
|
||||
"@vitejs/plugin-vue": "^5.0.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vue-tsc": "^1.8.27",
|
||||
"wxt": "^0.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user