Compare commits

...

53 Commits

Author SHA1 Message Date
GitHub Actions aea137a3a1 chore(release): v0.12.3 2023-12-23 04:26:47 +00:00
Aaron ad0e8e4144 fix: Correctly mock webextension-polyfill for Vitest (#294) 2023-12-22 22:21:09 -06:00
GitHub Actions 7fd5752ef1 chore(release): v0.12.2 2023-12-19 17:48:24 +00:00
Aaron 1a8b7ff635 feat: Support PNPM without hoisting dependencies (#291) 2023-12-19 11:42:20 -06:00
GitHub Actions fe3ca0fcbd chore(release): v0.12.1 2023-12-16 17:24:16 +00:00
Aaron f49ee9f005 fix: Fix package.json lint errors (#290) 2023-12-16 11:18:16 -06:00
Aaron 961440c0ac fix: Upgrade @webext-core/match-patterns to 1.0.3 (#289) 2023-12-16 11:07:18 -06:00
Aaron 9b562b0ca8 chore: Upgrade templates to wxt@^0.12.0 (#285) 2023-12-13 16:21:52 -06:00
Aaron Klinker 7b2563b2dc Update changelog with breaking changes 2023-12-13 16:02:24 -06:00
GitHub Actions 63f507ecf5 chore(release): v0.12.0 2023-12-13 21:57:34 +00:00
Aaron 0a88955394 fix: Throw error when no entrypoints are found (#283) 2023-12-13 15:50:28 -06:00
Aaron Klinker 291d25b675 docs: Fix dead links 2023-12-13 15:50:09 -06:00
Aaron 6f902cc598 docs: Improve content script UI guide (#272) 2023-12-13 15:43:40 -06:00
Aaron fde488ac82 feat!: Add support for "main world" content scripts (#284)
BREAKING CHANGE: `defineContentScript` and `defineBackground` are now exported from `wxt/sandbox` instead of `wxt/client`. If you use auto-imports, no changes are required. If you have disabled auto-imports, you'll need to manually update your import statements.
2023-12-13 15:41:39 -06:00
Aaron 0873c24ad8 chore: Convert WXT CLI to an ESM binary (#279) 2023-12-12 14:44:04 -06:00
Aaron 7c02497148 fix: Only use type imports for Vite (#278) 2023-12-12 14:24:42 -06:00
GitHub Actions 25441a3b97 chore(release): v0.11.2 2023-12-12 19:24:47 +00:00
Aaron f1cf866fe1 fix: Improve duplicate entrypoint name detection and catch the error before loading their config (#276) 2023-12-12 13:22:09 -06:00
Aaron d92a126489 fix: Discover .js, .jsx, and .tsx unlisted scripts correctly (#274) 2023-12-12 12:24:38 -06:00
Aaron Klinker bcb93afe4e chore: Add missing navigation item 2023-12-09 23:37:29 -06:00
Aaron 21cf3642cb docs: Improve content script UI docs (#268) 2023-12-09 23:36:35 -06:00
Andrew Walsh 2ef28ec0c4 Update content-scripts.md (#266) 2023-12-09 23:33:59 -06:00
Aaron 59094c9af3 chore: Update sSolid template to vite 5 (#265) 2023-12-07 23:05:08 -06:00
GitHub Actions ba41691296 chore(release): v0.11.1 2023-12-07 21:41:45 +00:00
Aaron 8786aa91b3 feat: Add util for detecting URL changes in content scripts (#264) 2023-12-07 13:29:33 -06:00
Aaron 0623d10653 chore: Upgrade templates to wxt@^0.11.0 (#263) 2023-12-06 11:39:03 -06:00
Aaron Klinker 85174d4994 Fix changelog 2023-12-06 11:33:49 -06:00
GitHub Actions 4e2dd5d618 chore(release): v0.11.0 2023-12-06 17:24:11 +00:00
Aaron 1a39a0d153 feat!: Vite 5 support (#261) 2023-12-06 11:18:52 -06:00
Aaron f922648dd7 chore: Abstract vite from WXT's core logic (#242) 2023-12-05 16:55:22 -06:00
Ítalo Brasil e6142e3608 docs: Adds tl;dv to homepage (#260) 2023-12-05 12:30:57 -06:00
Aaron ed73451fd0 chore: Speed up CI using pnpm instead of npm (#259) 2023-12-04 14:14:44 -06:00
GitHub Actions 8a25a2f60f chore(release): v0.10.4 2023-12-04 15:54:42 +00:00
Aaron 397e9a8b45 feat: Add config to customize outDir (#258) 2023-12-04 09:50:24 -06:00
dependabot[bot] 7cef7680de chore(deps): bump esbuild from 0.19.5 to 0.19.8 (#253)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-01 15:23:31 -06:00
dependabot[bot] 0c57375758 chore(deps-dev): bump tsx from 3.14.0 to 4.6.1 (#252)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-01 15:09:19 -06:00
dependabot[bot] 2c6af84165 chore(deps): bump rollup-plugin-visualizer from 5.9.2 to 5.9.3 (#256)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-01 15:08:06 -06:00
dependabot[bot] 58e1371701 chore(deps-dev): bump @types/lodash.merge from 4.6.8 to 4.6.9 (#255)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-01 15:06:58 -06:00
dependabot[bot] 0d8e7463b8 chore(deps-dev): bump prettier from 3.0.3 to 3.1.0 (#254)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-01 15:06:24 -06:00
Aaron 77eeacaf0f docs: Update sidepanel availability (#250) 2023-11-29 17:12:37 -06:00
冯不游 e80c134150 docs: Add Doozy to homepage (#249) 2023-11-29 07:21:46 -06:00
GitHub Actions 03c8ab4d6f chore(release): v0.10.3 2023-11-28 18:18:59 +00:00
Aaron df13b9705c fix(auto-imports): Don't add imports to node_module dependencies (#247) 2023-11-28 12:15:05 -06:00
Aaron 762ba0080d chore(deps): Upgrade to typescript 5.3 (#245) 2023-11-27 10:03:03 -06:00
Aaron Klinker 317b1b6dcc docs: Fix typo 2023-11-27 09:57:09 -06:00
Aaron 93175a6477 chore: Use defu for merging some config objects (#243) 2023-11-26 12:23:10 -06:00
Aaron 60d6707b11 chore: Use normalize-path instead of vite.normalizePath (#244) 2023-11-26 11:36:42 -06:00
Aaron Klinker 97cbda3dab ci: only print response headers from docs webhook 2023-11-25 14:57:04 -06:00
Aaron Klinker 1611c1dba6 ci: Publish docs on push to main 2023-11-25 14:51:30 -06:00
Aaron Klinker b59252284a Print verbose webhook 2023-11-25 14:48:05 -06:00
Aaron Klinker 742b99657a chore: Trigger docs upgrade via webhook 2023-11-25 14:45:41 -06:00
Aaron Klinker e2997a43e0 Fix docker push command 2023-11-24 14:18:27 -06:00
Aaron Klinker 21dead60fd Self-host docs 2023-11-24 14:16:18 -06:00
120 changed files with 3138 additions and 1868 deletions
+27
View File
@@ -0,0 +1,27 @@
name: Publish Docs
on:
push:
branches:
- main
workflow_dispatch:
inputs:
tag:
description: Docker Image Tag
required: true
default: latest
jobs:
publish:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
- uses: docker/login-action@v3
with:
registry: https://${{ secrets.DOCKER_REGISTRY_HOSTNAME }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- run: pnpm docs:build
- run: docker build docs/.vitepress -t ${{ secrets.DOCKER_REGISTRY_HOSTNAME }}/wxt/docs:${{ github.event.inputs.tag || 'latest' }}
- run: docker push ${{ secrets.DOCKER_REGISTRY_HOSTNAME }}/wxt/docs:${{ github.event.inputs.tag || 'latest' }}
- run: curl -X POST -i ${{ secrets.UPDATE_DOCS_WEBHOOK }}
+12 -3
View File
@@ -13,6 +13,12 @@ jobs:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
- run: pnpm format:check
lint:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
- run: pnpm lint
type-check:
runs-on: ubuntu-22.04
steps:
@@ -66,11 +72,14 @@ jobs:
working-directory: templates/${{ matrix.template }}
- run: npm i -D ../../wxt-*.tgz
working-directory: templates/${{ matrix.template }}
- run: npm run compile
- run: pnpm compile
if: matrix.template != 'svelte'
working-directory: templates/${{ matrix.template }}
- run: npm run check
- run: pnpm check
if: matrix.template == 'svelte'
working-directory: templates/${{ matrix.template }}
- run: npm run build
- run: pnpm build
working-directory: templates/${{ matrix.template }}
env:
# Debug Vite 5's deprecated CJS support
VITE_CJS_TRACE: true
+163
View File
@@ -1,5 +1,168 @@
# Changelog
## v0.12.3
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.2...v0.12.3)
### 🩹 Fixes
- Correctly mock `webextension-polyfill` for Vitest ([#294](https://github.com/wxt-dev/wxt/pull/294))
## v0.12.2
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.1...v0.12.2)
### 🚀 Enhancements
- Support PNPM without hoisting dependencies ([#291](https://github.com/wxt-dev/wxt/pull/291))
## v0.12.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.0...v0.12.1)
### 🩹 Fixes
- Upgrade `@webext-core/match-patterns` to `1.0.3` ([#289](https://github.com/wxt-dev/wxt/pull/289))
- Fix `package.json` lint errors ([#290](https://github.com/wxt-dev/wxt/pull/290))
### 🏡 Chore
- Upgrade templates to `wxt@^0.12.0` ([#285](https://github.com/wxt-dev/wxt/pull/285))
## v0.12.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.11.2...v0.12.0)
### 🚀 Enhancements
- ⚠️ Add support for "main world" content scripts ([#284](https://github.com/wxt-dev/wxt/pull/284))
### 🩹 Fixes
- Only use type imports for Vite ([#278](https://github.com/wxt-dev/wxt/pull/278))
- Throw error when no entrypoints are found ([#283](https://github.com/wxt-dev/wxt/pull/283))
### 📖 Documentation
- Improve content script UI guide ([#272](https://github.com/wxt-dev/wxt/pull/272))
- Fix dead links ([291d25b](https://github.com/wxt-dev/wxt/commit/291d25b))
### 🏡 Chore
- Convert WXT CLI to an ESM binary ([#279](https://github.com/wxt-dev/wxt/pull/279))
#### ⚠️ Breaking Changes
`defineContentScript` and `defineBackground` are now exported from `wxt/sandbox` instead of `wxt/client`. ([#284](https://github.com/wxt-dev/wxt/pull/284))
- If you use auto-imports, no changes are required.
- If you have disabled auto-imports, you'll need to manually update your import statements:
```diff
- import { defineBackground, defineContentScript } from 'wxt/client';
+ import { defineBackground, defineContentScript } from 'wxt/sandbox';
```
## v0.11.2
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.11.1...v0.11.2)
### 🩹 Fixes
- Discover `.js`, `.jsx`, and `.tsx` unlisted scripts correctly ([#274](https://github.com/wxt-dev/wxt/pull/274))
- Improve duplicate entrypoint name detection and catch the error before loading their config ([#276](https://github.com/wxt-dev/wxt/pull/276))
### 📖 Documentation
- Improve content script UI docs ([#268](https://github.com/wxt-dev/wxt/pull/268))
### 🏡 Chore
- Update sSolid template to vite 5 ([#265](https://github.com/wxt-dev/wxt/pull/265))
- Add missing navigation item ([bcb93af](https://github.com/wxt-dev/wxt/commit/bcb93af))
## v0.11.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.11.0...v0.11.1)
### 🚀 Enhancements
- Add util for detecting URL changes in content scripts ([#264](https://github.com/wxt-dev/wxt/pull/264))
### 🏡 Chore
- Upgrade templates to `wxt@^0.11.0` ([#263](https://github.com/wxt-dev/wxt/pull/263))
## v0.11.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.10.4...v0.11.0)
### 🚀 Enhancements
- ⚠️ Vite 5 support ([#261](https://github.com/wxt-dev/wxt/pull/261))
### 📖 Documentation
- Adds tl;dv to homepage ([#260](https://github.com/wxt-dev/wxt/pull/260))
### 🏡 Chore
- Speed up CI using `pnpm` instead of `npm` ([#259](https://github.com/wxt-dev/wxt/pull/259))
- Abstract vite from WXT's core logic ([#242](https://github.com/wxt-dev/wxt/pull/242))
#### ⚠️ Breaking Changes
- You will need to update any other Vite plugins to a version that supports Vite 5 ([#261](https://github.com/wxt-dev/wxt/pull/261))
### ❤️ Contributors
- Ítalo Brasil ([@italodeverdade](http://github.com/italodeverdade))
## v0.10.4
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.10.3...v0.10.4)
### 🚀 Enhancements
- Add config to customize `outDir` ([#258](https://github.com/wxt-dev/wxt/pull/258))
### 📖 Documentation
- Add Doozy to homepage ([#249](https://github.com/wxt-dev/wxt/pull/249))
- Update sidepanel availability ([#250](https://github.com/wxt-dev/wxt/pull/250))
### 🏡 Chore
- **deps-dev:** Bump prettier from 3.0.3 to 3.1.0 ([#254](https://github.com/wxt-dev/wxt/pull/254))
- **deps-dev:** Bump @types/lodash.merge from 4.6.8 to 4.6.9 ([#255](https://github.com/wxt-dev/wxt/pull/255))
- **deps-dev:** Bump tsx from 3.14.0 to 4.6.1 ([#252](https://github.com/wxt-dev/wxt/pull/252))
### ❤️ Contributors
- 冯不游
## v0.10.3
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.10.2...v0.10.3)
### 🩹 Fixes
- **auto-imports:** Don't add imports to `node_module` dependencies ([#247](https://github.com/wxt-dev/wxt/pull/247))
### 📖 Documentation
- Fix typo ([317b1b6](https://github.com/wxt-dev/wxt/commit/317b1b6))
### 🏡 Chore
- Trigger docs upgrade via webhook ([742b996](https://github.com/wxt-dev/wxt/commit/742b996))
- Use `normalize-path` instead of `vite.normalizePath` ([#244](https://github.com/wxt-dev/wxt/pull/244))
- Use `defu` for merging some config objects ([#243](https://github.com/wxt-dev/wxt/pull/243))
### 🤖 CI
- Publish docs on push to main ([1611c1d](https://github.com/wxt-dev/wxt/commit/1611c1d))
- Only print response headers from docs webhook ([97cbda3](https://github.com/wxt-dev/wxt/commit/97cbda3))
## v0.10.2
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.10.1...v0.10.2)
+1 -1
View File
@@ -86,7 +86,7 @@ Note that templates are hardcoded to a specific version of `wxt` from NPM, they
```diff
"devDependencies": {
"typescript": "^5.1.6",
"typescript": "^5.3.2",
"vite-plugin-solid": "^2.7.0",
- "wxt": "^0.8.0"
+ "wxt": "../.."
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env node
require('../dist/cli.cjs');
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../dist/cli.js';
+1 -3
View File
@@ -19,13 +19,11 @@
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"vitest": "^0.34.6",
"webextension-polyfill": "^0.10.0"
"vitest": "^1.1.0"
},
"devDependencies": {
"@types/react": "^18.2.34",
"@types/react-dom": "^18.2.14",
"@types/webextension-polyfill": "^0.10.5",
"sass": "^1.69.5",
"wxt": "workspace:*"
}
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import background from '../background';
browser.i18n.getMessage = () => 'fake-message';
@@ -7,6 +7,10 @@ const logMock = vi.fn();
console.log = logMock;
describe('Background Entrypoint', () => {
beforeEach(() => {
fakeBrowser.reset();
});
it("should log the extenion's runtime ID", () => {
const id = 'some-id';
fakeBrowser.runtime.id = id;
@@ -15,4 +19,11 @@ describe('Background Entrypoint', () => {
expect(logMock).toBeCalledWith(id);
});
it('should set the start time in storage', async () => {
background.main();
await new Promise((res) => setTimeout(res));
expect(await storage.getItem('session:startTime')).toBeDefined();
});
});
@@ -0,0 +1,11 @@
export default defineContentScript({
// Site that uses HTML5 history
matches: ['*://*.crunchyroll.com/*'],
main(ctx) {
ctx.addEventListener(window, 'wxt:locationchange', ({ newUrl, oldUrl }) => {
console.log('Location changed:', newUrl.href, oldUrl.href);
});
console.log('Watching for location change...');
},
});
@@ -0,0 +1,8 @@
export default defineContentScript({
matches: ['*://*/*'],
world: 'MAIN',
main() {
console.log(`Hello from ${location.hostname}!`);
},
});
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sidebar</title>
</head>
<body>
<p>Example</p>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
FROM lipanski/docker-static-website:latest
COPY dist .
@@ -10,6 +10,8 @@ const chromeExtensionIds = [
'mgmdkjcljneegjfajchedjpdhbadklcf', // Anime Skip Player
'bfbnagnphiehemkdgmmficmjfddgfhpl', // UltraWideo
'elfaihghhjjoknimpccccmkioofjjfkf', // StayFree - Website Blocker & Web Analytics
'okifoaikfmpfcamplcfjkpdnhfodpkil', // Doozy: Ai Made Easy
'lknmjhcajhfbbglglccadlfdjbaiifig', // tl;dv - Record, Transcribe & ChatGPT for Google Meet
];
const { data } = useListExtensionDetails(chromeExtensionIds);
+1
View File
@@ -91,6 +91,7 @@ export default defineConfig({
{ text: 'Manifest.json', link: '/guide/manifest.md' },
{ text: 'Extension APIs', link: '/guide/extension-apis.md' },
{ text: 'Storage', link: '/guide/storage.md' },
{ text: 'Content Script UI', link: '/guide/content-script-ui.md' },
{ text: 'Remote Code', link: '/guide/remote-code.md' },
{ text: 'Development', link: '/guide/development.md' },
{ text: 'Testing', link: '/guide/testing.md' },
-196
View File
@@ -137,199 +137,3 @@ export default defineContentScript({
},
});
```
## UI
WXT provides a utility function, `createContentScriptUi` to simplify mounting a UI from a content script. Internally, it uses the `ShadowRoot` API to isolate your CSS from the webpages.
`createContentScriptUi` requires a `ContentScriptContext` so that when the context is invalidated, the UI is automatically removed from the webpage.
:::details When to use `createContentScriptUi`
You should only use `createContentScriptUi` if you want your UI's styles isolated from the webpages. If you want to create a more "integrated" UI that uses the page's styles, you can just use the regular JS API's to append your UI to the page.
```ts
const ui = document.createElement('div');
const anchor = document.querySelector('#anchor-selector');
anchor.append(ui);
```
You can try out [`createContentScriptIframe`](#createcontentscriptiframe) as an alternative solution
:::
### Usage
To use `createContentScriptUi`, follow these steps:
1. Import your CSS file at the top of your content script
2. Set `cssInjectionMode: "ui"` inside `defineContentScript`
3. Call `createContentScriptUi`
4. Call `mount` to add the UI to the webpage
Here's a basic example:
```ts
// entrypoints/ui.content/index.ts
import './style.css';
export default defineContentScript({
// ...
cssInjectionMode: 'ui',
async main(ctx) {
const ui = await createContentScriptUi(ctx, {
name: 'example-ui',
type: 'inline',
anchor: '#some-element',
append: 'after',
mount(container) {
// Mount UI inside `container`...
},
});
// You must call `mount` to add the UI to the page.
ui.mount();
},
});
```
If you're using a frontend framework, you'll also need to include an `onRemoved` callback:
:::code-group
```ts [Vue]
import { createApp } from 'vue';
createContentScriptUi(ctx, {
// ...
mount(container) {
// Create a new app and mount it inside the container
const app = createApp(...);
app.mount(container);
return app;
},
onRemove(app) {
// When the UI is removed from the DOM, call unmount to stop the app
app.unmount();
},
});
```
```ts [React]
import ReactDOM from 'react-dom/client';
createContentScriptUi(ctx, {
// ...
mount(container) {
// Create a root using the container and render your app
const root = ReactDOM.createRoot(container);
root.render(...);
return root;
},
onRemove(root) {
// When the UI is removed from the DOM, call unmount to stop the app
root.unmount();
},
});
```
```ts [Svelte]
import App from './App.svelte';
createContentScriptUi(ctx, {
// ...
mount(container) {
// Mount your app component inside the container
return new App({
target: container,
});
},
onRemove(app) {
// When the UI is removed from the DOM, call $destroy to stop the app
app.$destroy();
},
});
```
```ts [Solid]
import { render } from 'solid-js/web';
createContentScriptUi(ctx, {
// ...
mount(container) {
// Render your app component into the container
return render(() => ..., container)
},
onRemove(unmount) {
// When the UI is removed from the DOM, call unmount to stop the app
unmount();
},
});
```
:::
### `anchor`
The anchor dictates where the UI will be mounted.
### `append`
Customize where the UI get's appended to the DOM, relative to the `anchor` element.
### `type`
There are 3 types of UI's you can mount.
- `inline`: Shows up inline based on the `anchor` and `append` options
- `overlay`: Shows up inline, but styled to be 0px by 0px, with overflow visible. This causes the UI to overlay on top of the webpage's content
- `modal`: A fullscreen overlay that covers the entire screen, regardless of where it's anchored.
> TODO: Add visualization of the different UI types.
### Overlay `alignment`
Because the overlay UI type results in a 0px by 0px container being added to the webpage, the `alignment` option allows you to configure which corner of your UI is aligned with the 0x0 element.
> TODO: Add visualization of the different alignments.
## IFrame
WXT provides a utility function, `createContentScriptIframe` to simplify mounting a UI from a content script. It creates an iframe to an unlisted HTML page. Unlike `createContentScriptUi`, this API support HMR.
`createContentScriptIframe` requires a `ContentScriptContext` so that when the context is invalidated, the UI is automatically removed from the webpage.
### Usage
To use `createContentScriptIframe`, follow these steps:
1. Create an unlisted HTML page that will be loaded into your iframe
1. Add unlisted page to the manifest's `web_accessible_resouces`
1. Call `createContentScriptUi`
1. Call `mount` to add the UI to the webpage
Here's a basic example:
```ts
export default defineContentScript({
// ...
async main(ctx) {
const ui = await createContentScriptIframe(ctx, {
page: '/your-unlisted-page.html',
type: 'inline',
anchor: '#some-element',
append: 'after',
});
// You must call `mount` to add the UI to the page.
ui.mount();
},
});
```
The options, other than `page`, are the same as [`createContentScriptUi`](#anchor).
### `page`
The HTML page you want to load inside the iframe. This string will be passed into `browser.runtime.getURL` to resolve the full path of to your HTML page.
+3 -3
View File
@@ -1,9 +1,9 @@
# Side Panel
[Chrome Docs](https://developer.chrome.com/docs/extensions/reference/sidePanel/)
[Chrome Docs](https://developer.chrome.com/docs/extensions/reference/sidePanel/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/user_interface/Sidebars)
:::tip Chromium Only
Firefox does not support sidepanel pages.
:::warning
Chrome added support for sidepanels in Manifest V3, they are not available in Manfiest V2.
:::
## Filenames
+4 -4
View File
@@ -11,13 +11,13 @@ To setup your test environment for auto-imports, see [Testing](/guide/testing).
Some WXT APIs can be used without importing them:
- [`browser`](/api/wxt/browser/variables/browser) from `wxt/browser`, a small wrapper around `webextension-polyfill`
- [`defineContentScript`](/api/wxt/client/functions/defineContentScript) from `wxt/client`
- [`defineBackground`](/api/wxt/client/functions/defineBackground) from `wxt/client`
- [`createContentScriptUi`](/api/wxt/client/functions/createContentScriptUi) from `wxt/client`
- [`defineContentScript`](/api/wxt/sandbox/functions/defineContentScript) from `wxt/sandbox`
- [`defineBackground`](/api/wxt/sandbox/functions/defineBackground) from `wxt/sandbox`
- [`defineUnlistedScript`](/api/wxt/sandbox/functions/defineUnlistedScript) from `wxt/sandbox`
- [`createContentScriptUi`](/api/wxt/client/functions/createContentScriptUi) from `wxt/client`
- [`fakeBrowser`](/api/wxt/testing/variables/fakeBrowser) from `wxt/testing`
And more. All `wxt/*` APIs can be used without imports.
And more!
## Project Auto-imports
+326
View File
@@ -0,0 +1,326 @@
# Content Script UI
There are three ways to mount a UI inside a content script:
[[toc]]
Each has their own set of advantages and disadvantages.
| Method | Isolated Styles | HMR | Use page's context |
| ---------- | :-------------: | :-: | :----------------: |
| Integrated | ❌ | ❌ | ✅ |
| ShadowRoot | ✅ | ❌ | ✅ |
| IFrame | ✅ | ✅ | ❌ |
## Integrated
Integrated content script UIs are injected alongside the content of a page. This means that they are affected by CSS on that page.
You can control how CSS is injected for an integrated content script UI with the [`cssInjectionMode`](/api/wxt/interfaces/ContentScriptBaseDefinition#cssinjectionmode) property.
:::code-group
```ts [Vanilla]
// entrypoints/example-ui.content.ts
export default defineContentScript({
main(ctx) {
// Create the UI container
const container = document.createElement('div');
// Add UI container to the page
const anchor = document.querySelector('#anchor');
anchore.append(container);
// Remove UI container when invalidated
ctx.onInvalidated(() => {
container.remove();
});
},
});
```
```ts [Vue]
// entrypoints/example-ui.content/index.ts
import { createApp } from 'vue';
export default defineContentScript({
main(ctx) {
// Create the UI container
const container = document.createElement('div');
// Create the app and mount it to the UI container
const app = createApp(...);
app.mount(container);
// Add UI container to the page
const anchor = document.querySelector('#anchor');
anchore.append(container);
// Unmount the app and remove UI container when invalidated
ctx.onInvalidated(() => {
app.unmount();
container.remove();
});
},
});
```
```tsx [React]
// entrypoints/example-ui.content/index.tsx
import ReactDOM from 'react-dom/client';
export default defineContentScript({
main(ctx) {
// Create the UI container
const container = document.createElement('div');
// Create a root on the UI container and render a component
const root = ReactDOM.createRoot(container);
root.render(...);
// Add UI container to the page
const anchor = document.querySelector('#anchor');
anchore.append(container);
// Unmount the root and remove UI container when invalidated
ctx.onInvalidated(() => {
root.unmount();
container.remove();
});
},
});
```
```ts [Svelete]
// entrypoints/example-ui.content/index.ts
import App from './App.svelte';
export default defineContentScript({
main(ctx) {
// Create the UI container
const container = document.createElement('div');
// Create the Svelte app inside the UI container
const app = new App({
target: ui,
});
// Add UI container to the page
const anchor = document.querySelector('#anchor');
anchore.append(container);
// Destroy the app and remove UI container when invalidated
ctx.onInvalidated(() => {
app.$destroy();
container.remove();
});
},
});
```
```tsx [Solid]
// entrypoints/example-ui.content/index.ts
import { render } from 'solid-js/web';
export default defineContentScript({
main(ctx) {
// Create the UI container
const container = document.createElement('div');
// Render your app to the UI container
const unmount = render(() => ..., container)
// Add UI container to the page
const anchor = document.querySelector('#anchor');
anchore.append(container);
// Unmount the app and remove UI container when invalidated
ctx.onInvalidated(() => {
unmount();
container.remove();
});
},
});
```
:::
## ShadowRoot
Often in web extensions, you don't want your content script's CSS affecting the page, or vise-versa. The [`ShadowRoot`](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot) API is ideal for this.
WXT provides a helper function, [`createContentScriptUi`](/api/wxt/client/functions/createContentScriptUi), that abstracts all the `ShadowRoot` setup away, making it easy to create UIs with isolated CSS.
To use `createContentScriptUi`, follow these steps:
1. Import your CSS file at the top of your content script
2. Set [`cssInjectionMode: "ui"`](/api/wxt/interfaces/ContentScriptBaseDefinition#cssinjectionmode) inside `defineContentScript`
3. Define your UI with `createContentScriptUi()`
4. Mount the UI so it is visible to users
```ts
// 1. Import the style
import './style.css';
export default defineContentScript({
// 2. Set cssInjectionMode
cssInjectionMode: 'ui',
async main(ctx) {
// 3. Define your UI
const ui = await createContentScriptUi(ctx, {
name: 'example-ui',
anchor: '#anchor',
type: 'inline',
mount(container) {
// Define how your UI will be mounted inside the container
const app = document.createElement('p');
app.textContent = 'Hello world!';
container.append(app);
},
});
// 4. Mount the UI
ui.mount();
},
});
```
> `createContentScriptUi` will automatically remove the UI from the page when the content script is invalidated.
See the [API Reference](/api/wxt/client/functions/createContentScriptUi) for the complete list of options.
:::info TailwindCSS
`createContentScriptUi` supports TailwindCSS out of the box! When importing the styles, just import the main CSS file containing the `@tailwind` directives, and everything will just work :+1:.
:::
When using a frontend framework for your UI, you'll need to unmount the app when the UI is removed. This is accomplished by returning an app reference from the `mount` option and by passing in a custom `onRemoved` option:
:::code-group
```ts [Vue]
import { createApp } from 'vue';
const ui = createContentScriptUi(ctx, {
// ...
mount(container) {
const app = createApp(App);
app.mount(container);
return app;
},
onRemove(app) {
app.unmount();
},
});
```
```tsx [React]
import ReactDOM from 'react-dom/client';
const ui = createContentScriptUi(ctx, {
// ...
mount(container) {
const root = ReactDOM.createRoot(container);
root.render(...);
return root;
},
onRemove(root) {
root.unmount();
},
});
```
```ts [Svelte]
import App from './App.svelte';
const ui = createContentScriptUi(ctx, {
// ...
mount(container) {
return new App({ target: container });
},
onRemove(app) {
app.$destry();
},
});
```
```tsx [Solid]
import { render } from 'solid-js/web';
const ui = createContentScriptUi(ctx, {
// ...
mount(container) {
return render(() => ..., container);
},
onRemove(unmount) {
unmount();
},
});
```
:::
:::warning
The `mount(container)` and `onRemove(app)` options passed into `createContentScriptUi` **_are different from_** the `ui.mount()` and `ui.remove()` functions available on the returned UI object.
You don't need to pass anything into `ui.mount()` and `ui.remove()` because **_you already defined how and where the UI will be mounted_** in the options passed into `createContentScriptUi`.
:::
## IFrame
If you don't need to run your UI in the same frame as the content script, you can use an IFrame to host your UI instead. Since an IFrame just hosts an HTML page, **_HMR is supported_**.
WXT provides a helper function, [`createContentScriptIframe`](/api/wxt/client/functions/createContentScriptUi), which simplifies setting up the IFrame.
1. Create an HTML page that will be loaded into your IFrame
```html
<!-- entrypoints/example-iframe.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Your Content Script IFrame</title>
</head>
<body>
<!-- ... -->
</body>
</html>
```
1. Add the page to the manifest's `web_accessible_resouces`
```ts
// wxt.config.ts
export default defineConfig({
manifest: {
web_accessible_resources: [
{
resources: ['example-iframe.html'],
matches: [...],
},
],
},
});
```
1. Create and mount the IFrame
```ts
export default defineContentScript({
// ...
async main(ctx) {
// Define the UI
const ui = await createContentScriptIframe(ctx, {
page: '/example-iframe.html',
anchor: '#anchor',
type: 'inline',
});
// Add styles to the iframe like width
ui.iframe.width = 123;
// Show UI to user
ui.mount();
},
});
```
See the [API Reference](/api/wxt/client/functions/createContentScriptUi) for the complete list of options.
+16 -151
View File
@@ -1,162 +1,27 @@
# Testing
WXT provides several utils for writing tests.
## Official Frameworks
## Unit tests
WXT officially supports [Vitest](https://vitest.dev/) for unit tests and either [Playwright](https://playwright.dev/) or [Puppeteer](https://pptr.dev/) for E2E tests against Chromium browsers.
If you're using auto-imports (enabled by default), [Vitest](https://vitest.dev/) is the only testing framework that supports them.
For details setting up each testing framework, see the official examples:
If you want to use a different testing library/framework (like Jest, mocha, node:test, etc), you can keep using it, but you have two options:
- [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)
1. Switch to Vitest (recommended)
2. Configure the testing library manually
- Disable auto-imports by setting `imports: false` in your `wxt.config.ts` file
- Manually add globals normally provided by WXT (like `__BROWSER__`) that you consume to the global scope before accessing them (`globalThis.__BROWSER__ = "chrome"`)
### Unofficial Frameworks
### Vitest Setup
Puppeteer and Playwright are the only E2E test runners that support Chrome Extensions. There are no other options at the time of writing.
Install vitest and add the `WxtVitest` plugin to your `vitest.config.ts` file.
There are other options for unit tests however, like [Jest](https://jestjs.io/), [Mocha](https://mochajs.org/), or [`node:test`](https://nodejs.org/api/test.html). **_WXT does not claim to support any of them_** because none of them support all of WXT's features, like TypeScript or auto-imports.
```sh
pnpm i -D vitest
```
If you want to try to use a different framework for unit tests, you will need to configure the environment manually:
```ts
// <root>/vitest.config.ts
import { defineConfig } from 'vitest/config';
import { WxtVitest } from 'wxt/testing';
- **Auto-imports**: Add `unimport` to your test environment or disable them by setting `imports: false` in your `wxt.config.ts` file
- **`browser` mock**: Mock the `webextension-polyfill` module globally with `wxt/dist/virtual/mock-browser.js`
- **[Remote Code Bundling](/guide/remote-code)**: If you use it, configure your environment to handle the `url:` module prefix
- **Global Variables**: If you consume them, manually define globals provided by WXT (like `__BROWSER__`) by adding them to the global scope before accessing them (`globalThis.__BROWSER__ = "chrome"`)
- **Import paths**: If you use the `@/` or `~/` path aliases, add them to your test environment
export default defineConfig({
plugins: [WxtVitest()],
test: {
server: {
deps: {
// Add any dependencies that import webextension-polyfill here, otherwise tests will attempt to import the real polyfill, breaking the
// TODO: Auto-detect these dependencies inside `WxtVitest` so maintaining this list manually isn't necessary
inline: [...],
},
},
},
});
```
And that's it. You're ready to start writing tests.
### Writing Tests
Here's a very basic test, written with a few different testing libraries, with a few different approaches for mocking the `browser` global.
:::code-group
```ts [Vitest]
import { describe, it, expect, vi } from 'vitest';
function logRuntimeId() {
// Vitest automatically mocks "browser" with "fakeBrowser"
console.log(browser.runtime.id);
}
describe('logRuntimeId', () => {
it("should log the extension's runtime ID", () => {
// Set a known ID on fakeBrowser for the test
const id = 'some-runtime-id';
fakeBrowser.runtime.id = id;
const logSpy = vi.spyOn(console, 'log');
logRuntimeId();
expect(logSpy).toBeCalledWith(id);
});
});
```
```ts [Jest - Manual Mock]
import { fakeBrowser } from 'wxt/testing';
import { browser } from 'wxt/browser';
function logRuntimeId() {
console.log(browser.runtime.id);
}
// Manually mock
jest.mock('wxt/browser', () => {
const { fakeBrowser } = require('wxt/testing');
return { browser: fakeBrowser };
});
describe('logRuntimeId', () => {
it("should log the extension's runtime ID", () => {
// Set a known ID on fakeBrowser for the test
const id = 'some-runtime-id';
fakeBrowser.runtime.id = id;
const logSpy = jest.spyOn(console, 'log');
logRuntimeId();
expect(logSpy).toBeCalledWith(id);
});
});
```
```ts [node:test - Parameterized]
import { describe, it, mock } from 'node:test';
import { assert } from 'node:assert';
import { fakeBrowser } from 'wxt/testing';
import { browser } from 'wxt/browser';
// Add browser as a parameter so fakeBrowser can be passed instead of browser
function logRuntimeId(browser = browser) {
console.log(browser.runtime.id);
}
describe('logRuntimeId', () => {
it("should log the extension's runtime ID", () => {
// Set a known ID on fakeBrowser for the test
const id = 'some-runtime-id';
fakeBrowser.runtime.id = id;
console.log = mock.fn();
// pass in fakeBrowser during tests
logRuntimeId(fakeBrowser);
assert.deepStrictEqual(console.log.mock.calls[0].arguments, [id]);
});
});
```
:::
:::warning
Without mocking the `browser` variable, you'll see errors like this:
```
This script should only be loaded in a browser extension.
```
:::
WXT provides an in-memory, partial implementation of `browser`, [`fakeBrowser`](/api/wxt/testing/variables/fakeBrowser), from the [`@webext-core/fake-browser`](https://webext-core.aklinker1.io/guide/fake-browser/) package. `fakeBrowser` works with all testing frameworks/libraries. See their docs for a list of [implemented APIs](https://webext-core.aklinker1.io/guide/fake-browser/implemented-apis.html) and more example tests.
## E2E Tests
WXT does not provide any utils for running E2E tests. There are two libraries you can use to run E2E tests for any chrome extension.
- [`playwright`](https://playwright.dev/docs/chrome-extensions) (recommended) - "A high-level API to automate web browsers"
- [`puppeteer`](https://pptr.dev/guides/chrome-extensions) - "A high-level API to control headless Chrome over the DevTools Protocol"
:::info
Note that both only support running tests on Chrome.
:::
Before running tests with either of these tools, you must build the extension with `wxt build` and then load the extension from the output directory in a new tab.
To test an extension's UI, like the popup or options page, you'll need to know the extension's ID to open the URL directly.
> _chrome-extension://`browser.runtime.id`/popup.html_
- Playwright provides an API to get your extension ID after it has been installed. [See their docs](https://playwright.dev/docs/chrome-extensions#testing).
- Puppeteer requires you know the ID before installing the extension, so you can hard code it into the URLs you open. Follow [Chrome's guide](https://developer.chrome.com/docs/extensions/mv3/manifest/key/) to setup a consistent runtime id.
:::info
You cannot test popups in their normal popup window, you have to open them in a tab.
:::
[Here's how Vitest is configured](https://github.com/wxt-dev/wxt/blob/main/src/testing/wxt-vitest-plugin.ts) for reference.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+18 -16
View File
@@ -17,14 +17,16 @@ describe('Auto Imports', () => {
export {}
declare global {
const ContentScriptContext: typeof import('wxt/client')['ContentScriptContext']
const InvalidMatchPattern: typeof import('wxt/sandbox')['InvalidMatchPattern']
const MatchPattern: typeof import('wxt/sandbox')['MatchPattern']
const browser: typeof import('wxt/browser')['browser']
const builtinDrivers: typeof import('wxt/storage')['builtinDrivers']
const createContentScriptIframe: typeof import('wxt/client')['createContentScriptIframe']
const createContentScriptUi: typeof import('wxt/client')['createContentScriptUi']
const createStorage: typeof import('wxt/storage')['createStorage']
const defineBackground: typeof import('wxt/client')['defineBackground']
const defineBackground: typeof import('wxt/sandbox')['defineBackground']
const defineConfig: typeof import('wxt')['defineConfig']
const defineContentScript: typeof import('wxt/client')['defineContentScript']
const defineContentScript: typeof import('wxt/sandbox')['defineContentScript']
const defineDriver: typeof import('wxt/storage')['defineDriver']
const defineUnlistedScript: typeof import('wxt/sandbox')['defineUnlistedScript']
const fakeBrowser: typeof import('wxt/testing')['fakeBrowser']
@@ -49,16 +51,16 @@ describe('Auto Imports', () => {
expect(await project.serializeFile('.wxt/wxt.d.ts'))
.toMatchInlineSnapshot(`
".wxt/wxt.d.ts
----------------------------------------
// Generated by wxt
/// <reference types=\\"vite/client\\" />
/// <reference types=\\"./types/imports.d.ts\\" />
/// <reference types=\\"./types/paths.d.ts\\" />
/// <reference types=\\"./types/i18n.d.ts\\" />
/// <reference types=\\"./types/globals.d.ts\\" />
"
`);
".wxt/wxt.d.ts
----------------------------------------
// Generated by wxt
/// <reference types="vite/client" />
/// <reference types="./types/imports.d.ts" />
/// <reference types="./types/paths.d.ts" />
/// <reference types="./types/i18n.d.ts" />
/// <reference types="./types/globals.d.ts" />
"
`);
});
});
@@ -91,10 +93,10 @@ describe('Auto Imports', () => {
".wxt/wxt.d.ts
----------------------------------------
// Generated by wxt
/// <reference types=\\"vite/client\\" />
/// <reference types=\\"./types/paths.d.ts\\" />
/// <reference types=\\"./types/i18n.d.ts\\" />
/// <reference types=\\"./types/globals.d.ts\\" />
/// <reference types="vite/client" />
/// <reference types="./types/paths.d.ts" />
/// <reference types="./types/i18n.d.ts" />
/// <reference types="./types/globals.d.ts" />
"
`,
);
+15 -7
View File
@@ -174,6 +174,7 @@ describe('Manifest Content', () => {
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');
@@ -196,6 +197,7 @@ describe('Manifest Content', () => {
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');
@@ -207,6 +209,7 @@ describe('Manifest Content', () => {
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');
@@ -371,7 +374,7 @@ describe('Manifest Content', () => {
.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\\"]}]}"
{"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"]}]}"
`);
});
@@ -398,7 +401,7 @@ describe('Manifest Content', () => {
.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\\"]}]}"
{"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"]}]}"
`);
});
@@ -425,7 +428,7 @@ describe('Manifest Content', () => {
.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\\"]}]}"
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","content_scripts":[{"matches":["https://*.google.com/*"],"js":["content-scripts/content.js"]}]}"
`);
});
@@ -454,7 +457,7 @@ describe('Manifest Content', () => {
.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\\"]}"
{"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"]}"
`);
});
@@ -483,7 +486,7 @@ describe('Manifest Content', () => {
.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/*\\"]}]}"
{"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/*"]}]}"
`);
});
});
@@ -518,12 +521,13 @@ describe('Manifest Content', () => {
.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\\"]}]}"
{"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';
@@ -543,7 +547,7 @@ describe('Manifest Content', () => {
expect(output).toMatchInlineSnapshot(`
".output/chrome-mv3/manifest.json
----------------------------------------
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"author\\":\\"Custom Author\\"}"
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","author":"Custom Author"}"
`);
});
@@ -587,6 +591,7 @@ describe('Manifest Content', () => {
const project = new TestProject({
version: '1.0.0-alpha1',
});
project.addFile('entrypoints/unlisted.html');
await project.build({ browser, manifestVersion });
const manifest = await project.getOutputManifest(
@@ -604,6 +609,7 @@ describe('Manifest Content', () => {
const project = new TestProject({
version: '1.0.0-alpha1',
});
project.addFile('entrypoints/unlisted.html');
await project.build({ browser, manifestVersion });
const manifest = await project.getOutputManifest(
@@ -626,6 +632,7 @@ describe('Manifest Content', () => {
const project = new TestProject({
version: '1.0.0.1',
});
project.addFile('entrypoints/unlisted.html');
await project.build({ browser, manifestVersion });
const manifest = await project.getOutputManifest(
@@ -640,6 +647,7 @@ describe('Manifest Content', () => {
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: {
+23 -4
View File
@@ -7,13 +7,18 @@ describe('Output Directory Structure', () => {
project.addFile('entrypoints/.DS_Store');
project.addFile('entrypoints/.hidden1/index.html');
project.addFile('entrypoints/.hidden2.html');
project.addFile('entrypoints/unlisted.html');
await project.build();
expect(await project.serializeOutput()).toMatchInlineSnapshot(`
".output/chrome-mv3/manifest.json
----------------------------------------
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\"}"
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0"}
================================================================================
.output/chrome-mv3/unlisted.html
----------------------------------------
"
`);
});
@@ -69,7 +74,7 @@ describe('Output Directory Structure', () => {
================================================================================
.output/chrome-mv3/manifest.json
----------------------------------------
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"content_scripts\\":[{\\"matches\\":[\\"*://*/*\\"],\\"css\\":[\\"content-scripts/one.css\\",\\"content-scripts/two.css\\"],\\"js\\":[\\"content-scripts/one.js\\",\\"content-scripts/two.js\\"]}]}"
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","content_scripts":[{"matches":["*://*/*"],"css":["content-scripts/one.css","content-scripts/two.css"],"js":["content-scripts/one.js","content-scripts/two.js"]}]}"
`);
});
@@ -96,7 +101,7 @@ describe('Output Directory Structure', () => {
================================================================================
.output/chrome-mv3/manifest.json
----------------------------------------
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"content_scripts\\":[{\\"matches\\":[\\"*://*/*\\"],\\"js\\":[\\"content-scripts/overlay-one.js\\"]}]}"
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","content_scripts":[{"matches":["*://*/*"],"js":["content-scripts/overlay-one.js"]}]}"
`);
});
@@ -197,7 +202,7 @@ describe('Output Directory Structure', () => {
.toMatchInlineSnapshot(`
".output/chrome-mv3/manifest.json
----------------------------------------
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"background\\":{\\"service_worker\\":\\"background.js\\"},\\"content_scripts\\":[{\\"matches\\":[\\"*://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]},{\\"matches\\":[\\"*://*.duckduckgo.com/*\\"],\\"js\\":[\\"content-scripts/named.js\\"]}]}"
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","background":{"service_worker":"background.js"},"content_scripts":[{"matches":["*://*.google.com/*"],"js":["content-scripts/content.js"]},{"matches":["*://*.duckduckgo.com/*"],"js":["content-scripts/named.js"]}]}"
`);
expect(await project.fileExists('.output/chrome-mv3/background.js'));
expect(
@@ -208,4 +213,18 @@ describe('Output Directory Structure', () => {
);
expect(await project.fileExists('.output/chrome-mv3/unlisted.js'));
});
it("should output to a custom directory when overriding 'outDir'", async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
project.setConfigFileConfig({
outDir: 'dist',
});
await project.build();
expect(await project.fileExists('dist/chrome-mv3/manifest.json')).toBe(
true,
);
});
});
+1 -1
View File
@@ -37,7 +37,7 @@ describe('React', () => {
.toMatchInlineSnapshot(`
".output/chrome-mv3/manifest.json
----------------------------------------
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"content_scripts\\":[{\\"matches\\":\\"<all_urls>\\",\\"js\\":[\\"content-scripts/demo.js\\"]}]}"
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","content_scripts":[{"matches":"<all_urls>","js":["content-scripts/demo.js"]}]}"
`);
});
});
+112 -106
View File
@@ -4,6 +4,7 @@ import { TestProject } from '../utils';
describe('TypeScript Project', () => {
it('should generate defined constants correctly', async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
await project.build();
@@ -21,7 +22,7 @@ describe('TypeScript Project', () => {
const __IS_SAFARI__: boolean;
const __IS_EDGE__: boolean;
const __IS_OPERA__: boolean;
const __COMMAND__: \\"build\\" | \\"serve\\";
const __COMMAND__: "build" | "serve";
const __ENTRYPOINT__: string;
}
"
@@ -41,13 +42,13 @@ describe('TypeScript Project', () => {
".wxt/types/paths.d.ts
----------------------------------------
// Generated by wxt
import \\"wxt/browser\\";
import "wxt/browser";
declare module \\"wxt/browser\\" {
declare module "wxt/browser" {
export type PublicPath =
| \\"/options.html\\"
| \\"/popup.html\\"
| \\"/sandbox.html\\"
| "/options.html"
| "/popup.html"
| "/sandbox.html"
export interface WxtRuntime extends Runtime.Static {
getURL(path: PublicPath): string;
}
@@ -58,6 +59,7 @@ describe('TypeScript Project', () => {
it('should augment the types for browser.i18n.getMessage', async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
project.addFile(
'public/_locales/en/messages.json',
JSON.stringify({
@@ -103,9 +105,9 @@ describe('TypeScript Project', () => {
".wxt/types/i18n.d.ts
----------------------------------------
// Generated by wxt
import \\"wxt/browser\\";
import "wxt/browser";
declare module \\"wxt/browser\\" {
declare module "wxt/browser" {
/**
* See https://developer.chrome.com/docs/extensions/reference/i18n/#method-getMessage
*/
@@ -121,90 +123,90 @@ describe('TypeScript Project', () => {
* The extension or app ID; you might use this string to construct URLs for resources inside the extension. Even unlocalized extensions can use this message.
Note: You can't use this message in a manifest file.
*
* \\"<browser.runtime.id>\\"
* "<browser.runtime.id>"
*/
getMessage(
messageName: \\"@@extension_id\\",
messageName: "@@extension_id",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
*
*
* \\"<browser.i18n.getUiLocale()>\\"
* "<browser.i18n.getUiLocale()>"
*/
getMessage(
messageName: \\"@@ui_locale\\",
messageName: "@@ui_locale",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* The text direction for the current locale, either \\"ltr\\" for left-to-right languages such as English or \\"rtl\\" for right-to-left languages such as Japanese.
* The text direction for the current locale, either "ltr" for left-to-right languages such as English or "rtl" for right-to-left languages such as Japanese.
*
* \\"<ltr|rtl>\\"
* "<ltr|rtl>"
*/
getMessage(
messageName: \\"@@bidi_dir\\",
messageName: "@@bidi_dir",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* If the @@bidi_dir is \\"ltr\\", then this is \\"rtl\\"; otherwise, it's \\"ltr\\".
* If the @@bidi_dir is "ltr", then this is "rtl"; otherwise, it's "ltr".
*
* \\"<rtl|ltr>\\"
* "<rtl|ltr>"
*/
getMessage(
messageName: \\"@@bidi_reversed_dir\\",
messageName: "@@bidi_reversed_dir",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* If the @@bidi_dir is \\"ltr\\", then this is \\"left\\"; otherwise, it's \\"right\\".
* If the @@bidi_dir is "ltr", then this is "left"; otherwise, it's "right".
*
* \\"<left|right>\\"
* "<left|right>"
*/
getMessage(
messageName: \\"@@bidi_start_edge\\",
messageName: "@@bidi_start_edge",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* If the @@bidi_dir is \\"ltr\\", then this is \\"right\\"; otherwise, it's \\"left\\".
* If the @@bidi_dir is "ltr", then this is "right"; otherwise, it's "left".
*
* \\"<right|left>\\"
* "<right|left>"
*/
getMessage(
messageName: \\"@@bidi_end_edge\\",
messageName: "@@bidi_end_edge",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* Ask for the user's name
*
* \\"What's your name?\\"
* "What's your name?"
*/
getMessage(
messageName: \\"prompt_for_name\\",
messageName: "prompt_for_name",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* Greet the user
*
* \\"Hello, $USER$\\"
* "Hello, $USER$"
*/
getMessage(
messageName: \\"hello\\",
messageName: "hello",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* Say goodbye to the user
*
* \\"Goodbye, $USER$. Come back to $OUR_SITE$ soon!\\"
* "Goodbye, $USER$. Come back to $OUR_SITE$ soon!"
*/
getMessage(
messageName: \\"bye\\",
messageName: "bye",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
@@ -216,6 +218,7 @@ describe('TypeScript Project', () => {
it('should reference all the required types in a single declaration file', async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
await project.build();
@@ -224,17 +227,18 @@ describe('TypeScript Project', () => {
".wxt/wxt.d.ts
----------------------------------------
// Generated by wxt
/// <reference types=\\"vite/client\\" />
/// <reference types=\\"./types/imports.d.ts\\" />
/// <reference types=\\"./types/paths.d.ts\\" />
/// <reference types=\\"./types/i18n.d.ts\\" />
/// <reference types=\\"./types/globals.d.ts\\" />
/// <reference types="vite/client" />
/// <reference types="./types/imports.d.ts" />
/// <reference types="./types/paths.d.ts" />
/// <reference types="./types/i18n.d.ts" />
/// <reference types="./types/globals.d.ts" />
"
`);
});
it('should generate a TSConfig file for the project', async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
await project.build();
@@ -243,38 +247,39 @@ describe('TypeScript Project', () => {
".wxt/tsconfig.json
----------------------------------------
{
\\"compilerOptions\\": {
\\"target\\": \\"ESNext\\",
\\"module\\": \\"ESNext\\",
\\"moduleResolution\\": \\"Bundler\\",
\\"noEmit\\": true,
\\"esModuleInterop\\": true,
\\"forceConsistentCasingInFileNames\\": true,
\\"resolveJsonModule\\": true,
\\"strict\\": true,
\\"skipLibCheck\\": true,
\\"paths\\": {
\\"@\\": [\\"..\\"],
\\"@/*\\": [\\"../*\\"],
\\"~\\": [\\"..\\"],
\\"~/*\\": [\\"../*\\"],
\\"@@\\": [\\"..\\"],
\\"@@/*\\": [\\"../*\\"],
\\"~~\\": [\\"..\\"],
\\"~~/*\\": [\\"../*\\"]
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"paths": {
"@": [".."],
"@/*": ["../*"],
"~": [".."],
"~/*": ["../*"],
"@@": [".."],
"@@/*": ["../*"],
"~~": [".."],
"~~/*": ["../*"]
}
},
\\"include\\": [
\\"../**/*\\",
\\"./wxt.d.ts\\"
"include": [
"../**/*",
"./wxt.d.ts"
],
\\"exclude\\": [\\"../.output\\"]
"exclude": ["../.output"]
}"
`);
});
it('should generate correct path aliases for a custom srcDir', async () => {
const project = new TestProject();
project.addFile('src/entrypoints/unlisted.html');
project.setConfigFileConfig({
srcDir: 'src',
});
@@ -286,38 +291,39 @@ describe('TypeScript Project', () => {
".wxt/tsconfig.json
----------------------------------------
{
\\"compilerOptions\\": {
\\"target\\": \\"ESNext\\",
\\"module\\": \\"ESNext\\",
\\"moduleResolution\\": \\"Bundler\\",
\\"noEmit\\": true,
\\"esModuleInterop\\": true,
\\"forceConsistentCasingInFileNames\\": true,
\\"resolveJsonModule\\": true,
\\"strict\\": true,
\\"skipLibCheck\\": true,
\\"paths\\": {
\\"@\\": [\\"../src\\"],
\\"@/*\\": [\\"../src/*\\"],
\\"~\\": [\\"../src\\"],
\\"~/*\\": [\\"../src/*\\"],
\\"@@\\": [\\"..\\"],
\\"@@/*\\": [\\"../*\\"],
\\"~~\\": [\\"..\\"],
\\"~~/*\\": [\\"../*\\"]
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"paths": {
"@": ["../src"],
"@/*": ["../src/*"],
"~": ["../src"],
"~/*": ["../src/*"],
"@@": [".."],
"@@/*": ["../*"],
"~~": [".."],
"~~/*": ["../*"]
}
},
\\"include\\": [
\\"../**/*\\",
\\"./wxt.d.ts\\"
"include": [
"../**/*",
"./wxt.d.ts"
],
\\"exclude\\": [\\"../.output\\"]
"exclude": ["../.output"]
}"
`);
});
it('should add additional path aliases listed in the alias config, preventing defaults from being overridden', async () => {
const project = new TestProject();
project.addFile('src/entrypoints/unlisted.html');
project.setConfigFileConfig({
srcDir: 'src',
alias: {
@@ -333,34 +339,34 @@ describe('TypeScript Project', () => {
".wxt/tsconfig.json
----------------------------------------
{
\\"compilerOptions\\": {
\\"target\\": \\"ESNext\\",
\\"module\\": \\"ESNext\\",
\\"moduleResolution\\": \\"Bundler\\",
\\"noEmit\\": true,
\\"esModuleInterop\\": true,
\\"forceConsistentCasingInFileNames\\": true,
\\"resolveJsonModule\\": true,
\\"strict\\": true,
\\"skipLibCheck\\": true,
\\"paths\\": {
\\"example\\": [\\"../example\\"],
\\"example/*\\": [\\"../example/*\\"],
\\"@\\": [\\"../src\\"],
\\"@/*\\": [\\"../src/*\\"],
\\"~\\": [\\"../src\\"],
\\"~/*\\": [\\"../src/*\\"],
\\"@@\\": [\\"..\\"],
\\"@@/*\\": [\\"../*\\"],
\\"~~\\": [\\"..\\"],
\\"~~/*\\": [\\"../*\\"]
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"paths": {
"example": ["../example"],
"example/*": ["../example/*"],
"@": ["../src"],
"@/*": ["../src/*"],
"~": ["../src"],
"~/*": ["../src/*"],
"@@": [".."],
"@@/*": ["../*"],
"~~": [".."],
"~~/*": ["../*"]
}
},
\\"include\\": [
\\"../**/*\\",
\\"./wxt.d.ts\\"
"include": [
"../**/*",
"./wxt.d.ts"
],
\\"exclude\\": [\\"../.output\\"]
"exclude": ["../.output"]
}"
`);
});
+6 -5
View File
@@ -29,7 +29,7 @@ describe('User Config', () => {
================================================================================
.output/chrome-mv3/manifest.json
----------------------------------------
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"background\\":{\\"service_worker\\":\\"background.js\\"}}"
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","background":{"service_worker":"background.js"}}"
`);
});
@@ -55,12 +55,13 @@ describe('User Config', () => {
================================================================================
.output/chrome-mv3/manifest.json
----------------------------------------
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"background\\":{\\"service_worker\\":\\"background.js\\"}}"
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","background":{"service_worker":"background.js"}}"
`);
});
it('should merge inline and user config based manifests', async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
project.addFile(
'wxt.config.ts',
`import { defineConfig } from 'wxt';
@@ -79,11 +80,11 @@ describe('User Config', () => {
}),
});
const output = await project.serializeOutput();
expect(output).toMatchInlineSnapshot(`
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
.toMatchInlineSnapshot(`
".output/chrome-mv3/manifest.json
----------------------------------------
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"example_customization\\":[\\"production\\",\\"chrome\\",\\"3\\",\\"build\\"]}"
{"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0","example_customization":["3","build","production","chrome"]}"
`);
});
+3 -1
View File
@@ -77,7 +77,9 @@ export class TestProject {
await fs.writeFile(filePath, content ?? '', 'utf-8');
}
await execaCommand('npm i --ignore-scripts', { cwd: this.root });
await execaCommand('pnpm --ignore-workspace i --ignore-scripts', {
cwd: this.root,
});
await build({ ...config, root: this.root });
}
+63 -38
View File
@@ -1,7 +1,7 @@
{
"name": "wxt",
"type": "module",
"version": "0.10.2",
"version": "0.12.3",
"description": "Next gen framework for developing web extensions",
"engines": {
"node": ">=18",
@@ -30,45 +30,65 @@
"bin",
"dist"
],
"bin": "./bin/wxt.cjs",
"bin": "./bin/wxt.mjs",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"require": "./dist/index.cjs",
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./client": {
"import": "./dist/client.js",
"types": "./dist/client.d.ts"
"types": "./dist/client.d.ts",
"import": "./dist/client.js"
},
"./sandbox": {
"import": "./dist/sandbox.js",
"types": "./dist/sandbox.d.ts"
"types": "./dist/sandbox.d.ts",
"import": "./dist/sandbox.js"
},
"./browser": {
"import": "./dist/browser.js",
"types": "./dist/browser.d.ts"
"types": "./dist/browser.d.ts",
"import": "./dist/browser.js"
},
"./testing": {
"require": "./dist/testing.cjs",
"import": "./dist/testing.js",
"types": "./dist/testing.d.ts"
"import": {
"types": "./dist/testing.d.ts",
"default": "./dist/testing.js"
},
"require": {
"types": "./dist/testing.d.cts",
"default": "./dist/testing.cjs"
}
},
"./storage": {
"require": "./dist/storage.cjs",
"import": "./dist/storage.js",
"types": "./dist/storage.d.ts"
"import": {
"types": "./dist/storage.d.ts",
"default": "./dist/storage.js"
},
"require": {
"types": "./dist/storage.d.cts",
"default": "./dist/storage.cjs"
}
}
},
"scripts": {
"wxt": "tsx src/cli.ts",
"build": "tsx scripts/build.ts",
"format": "prettier --write .",
"format:check": "prettier --write .",
"compile": "tsc --noEmit",
"format:check": "prettier --check .",
"lint": "run-p -c -s lint:*",
"lint:eslint": "echo 'ESLint: TODO'",
"lint:package": "publint",
"compile": "run-s -c compile:*",
"compile:wxt": "tsc --noEmit",
"compile:virtual": "tsc --noEmit -p src/virtual",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"prepare": "simple-git-hooks",
@@ -83,11 +103,13 @@
"@types/webextension-polyfill": "^0.10.5",
"@webext-core/fake-browser": "^1.3.1",
"@webext-core/isolated-element": "^1.0.4",
"@webext-core/match-patterns": "^1.0.2",
"@webext-core/match-patterns": "^1.0.3",
"async-mutex": "^0.4.0",
"c12": "^1.5.1",
"cac": "^6.7.14",
"chokidar": "^3.5.3",
"consola": "^3.2.3",
"defu": "^6.1.3",
"esbuild": "^0.19.5",
"fast-glob": "^3.3.1",
"filesize": "^10.0.8",
@@ -100,42 +122,45 @@
"json5": "^2.2.3",
"linkedom": "^0.16.1",
"minimatch": "^9.0.3",
"normalize-path": "^3.0.0",
"ora": "^7.0.1",
"picocolors": "^1.0.0",
"prompts": "^2.4.2",
"rollup-plugin-visualizer": "^5.9.2",
"unimport": "^3.4.0",
"unstorage": "^1.9.0",
"vite": "^4.0.0 || ^5.0.0-0",
"vite": "^5.0.0",
"web-ext-run": "^0.1.0",
"webextension-polyfill": "^0.10.0",
"zip-dir": "^2.0.0"
},
"devDependencies": {
"@faker-js/faker": "^8.2.0",
"@types/fs-extra": "^11.0.3",
"@types/lodash.merge": "^4.6.8",
"@types/node": "^20.8.10",
"@types/prompts": "^2.4.7",
"@vitest/coverage-v8": "^0.34.6",
"@faker-js/faker": "^8.3.1",
"@types/fs-extra": "^11.0.4",
"@types/lodash.merge": "^4.6.9",
"@types/node": "^20.10.3",
"@types/normalize-path": "^3.0.2",
"@types/prompts": "^2.4.9",
"@vitest/coverage-v8": "^1.0.1",
"execa": "^8.0.1",
"happy-dom": "^12.4.0",
"lint-staged": "^15.0.2",
"happy-dom": "^12.10.3",
"lint-staged": "^15.2.0",
"lodash.merge": "^4.6.2",
"npm-run-all": "^4.1.5",
"p-map": "^6.0.0",
"prettier": "^3.0.1",
"p-map": "^7.0.0",
"prettier": "^3.1.0",
"publint": "^0.2.6",
"simple-git-hooks": "^2.9.0",
"tsup": "^7.2.0",
"tsx": "^3.12.7",
"typedoc": "^0.25.3",
"tsup": "^8.0.1",
"tsx": "^4.6.2",
"typedoc": "^0.25.4",
"typedoc-plugin-markdown": "4.0.0-next.23",
"typedoc-vitepress-theme": "1.0.0-next.3",
"typescript": "^5.2.2",
"vitepress": "1.0.0-rc.24",
"vitest": "^0.34.6",
"typescript": "^5.3.2",
"vitepress": "1.0.0-rc.31",
"vitest": "^1.1.0",
"vitest-mock-extended": "^1.3.1",
"vue": "^3.3.7"
"vue": "^3.3.10"
},
"packageManager": "pnpm@8.6.3",
"simple-git-hooks": {
+946 -446
View File
File diff suppressed because it is too large Load Diff
+10 -7
View File
@@ -16,17 +16,17 @@ const startTime = Date.now();
const outDir = 'dist';
await fs.rm(path.join(outDir, '*'), { recursive: true, force: true });
const preset: tsup.Options = {
const preset = {
dts: true,
silent: true,
sourcemap: false,
external: [
'vite',
'virtual:user-unlisted-script',
'virtual:user-content-script',
'virtual:user-content-script-isolated-world',
'virtual:user-content-script-main-world',
'virtual:user-background',
],
};
} satisfies tsup.Options;
function spinnerPMap(configs: tsup.Options[]) {
let completed = 0;
@@ -76,8 +76,10 @@ const config: tsup.Options[] = [
...preset,
entry: {
'virtual/background-entrypoint': 'src/virtual/background-entrypoint.ts',
'virtual/content-script-entrypoint':
'src/virtual/content-script-entrypoint.ts',
'virtual/content-script-isolated-world-entrypoint':
'src/virtual/content-script-isolated-world-entrypoint.ts',
'virtual/content-script-main-world-entrypoint':
'src/virtual/content-script-main-world-entrypoint.ts',
'virtual/mock-browser': 'src/virtual/mock-browser.ts',
'virtual/reload-html': 'src/virtual/reload-html.ts',
'virtual/unlisted-script-entrypoint':
@@ -86,6 +88,7 @@ const config: tsup.Options[] = [
format: ['esm'],
splitting: false,
dts: false,
external: [...preset.external, 'wxt'],
},
// CJS-only
{
@@ -93,7 +96,7 @@ const config: tsup.Options[] = [
entry: {
cli: 'src/cli.ts',
},
format: ['cjs'],
format: ['esm'],
},
];
+62
View File
@@ -17,3 +17,65 @@ export interface WxtI18n extends I18n.Static {
}
export const browser: AugmentedBrowser = originalBrowser;
// re-export all the types from webextension-polyfill
// Because webextension-polyfill uses a weird namespace with "import export", there isn't a good way
// to get these types without re-listing them.
/** @ignore */
export type {
ActivityLog,
Alarms,
Bookmarks,
Action,
BrowserAction,
BrowserSettings,
BrowsingData,
CaptivePortal,
Clipboard,
Commands,
ContentScripts,
ContextualIdentities,
Cookies,
DeclarativeNetRequest,
Devtools,
Dns,
Downloads,
Events,
Experiments,
Extension,
ExtensionTypes,
Find,
GeckoProfiler,
History,
I18n,
Identity,
Idle,
Management,
Manifest,
ContextMenus,
Menus,
NetworkStatus,
NormandyAddonStudy,
Notifications,
Omnibox,
PageAction,
Permissions,
Pkcs11,
Privacy,
Proxy,
Runtime,
Scripting,
Search,
Sessions,
SidebarAction,
Storage,
Tabs,
Theme,
TopSites,
Types,
Urlbar,
UserScripts,
WebNavigation,
WebRequest,
Windows,
} from 'webextension-polyfill';
@@ -1,6 +1,8 @@
import { ContentScriptDefinition } from '~/types';
import { browser } from '~/browser';
import { logger } from '~/client/utils/logger';
import { logger } from '~/sandbox/utils/logger';
import { WxtLocationChangeEvent, getUniqueEventName } from './custom-events';
import { createLocationWatcher } from './location-watcher';
/**
* Implements [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController).
@@ -14,6 +16,7 @@ export class ContentScriptContext implements AbortController {
#isTopFrame = window.self === window.top;
#abortController: AbortController;
#locationWatcher = createLocationWatcher(this);
constructor(
private readonly contentScriptName: string,
@@ -137,23 +140,42 @@ export class ContentScriptContext implements AbortController {
/**
* Call `target.addEventListener` and remove the event listener when the context is invalidated.
*
* Includes additional events useful for content scripts:
*
* - `"wxt:locationchange"` - Triggered when HTML5 history mode is used to change URL. Content
* scripts are not reloaded when navigating this way, so this can be used to reset the content
* script state on URL change, or run custom code.
*
* @example
* ctx.addEventListener(window, "mousemove", () => {
* // ...
* });
* ctx.addEventListener(document, "visibilitychange", () => {
* // ...
* });
* ctx.addEventListener(document, "wxt:locationchange", () => {
* // ...
* });
*/
addEventListener(
target: any,
type: string,
handler: (event: Event) => void,
addEventListener<
TTarget extends EventTarget,
TType extends keyof WxtContentScriptEventMap,
>(
target: TTarget,
type: TType,
handler: (event: WxtContentScriptEventMap[TType]) => void,
options?: AddEventListenerOptions,
) {
target.addEventListener?.(type, handler, options);
this.onInvalidated(
() => target.removeEventListener?.(type, handler, options),
if (type === 'wxt:locationchange') {
// Start the location watcher when adding the event for the first time
if (this.isValid) this.#locationWatcher.run();
}
target.addEventListener?.(
type.startsWith('wxt:') ? getUniqueEventName(type) : type,
// @ts-expect-error: Event don't match, but that's OK, EventTarget doesn't allow custom types in the callback
handler,
{
...options,
signal: this.signal,
},
);
}
@@ -193,3 +215,7 @@ export class ContentScriptContext implements AbortController {
this.onInvalidated(() => removeEventListener('message', cb));
}
}
interface WxtContentScriptEventMap extends WindowEventMap {
'wxt:locationchange': WxtLocationChangeEvent;
}
@@ -1,4 +1,4 @@
import browser from 'webextension-polyfill';
import { browser } from '~/browser';
import {
ContentScriptAnchoredOptions,
ContentScriptPositioningOptions,
@@ -1,6 +1,6 @@
import { createIsolatedElement } from '@webext-core/isolated-element';
import { browser } from '~/browser';
import { logger } from '~/client/utils/logger';
import { logger } from '~/sandbox/utils/logger';
import { ContentScriptContext } from './content-script-context';
import {
ContentScriptAnchoredOptions,
@@ -13,7 +13,7 @@ import {
* Utility for mounting content script UI's with isolated styles. Automatically removed from the DOM
* when the content script's context is invalidated.
*
* See https://wxt.dev/entrypoints/content-scripts.html#ui for full documentation.
* See https://wxt.dev/guide/content-script-ui.html for full documentation.
*
* @example
* // entrypoints/example-ui.content/index.ts
@@ -167,7 +167,7 @@ export type ContentScriptUiOptions<TApp> = ContentScriptPositioningOptions &
* set `cssInjectionMode: "ui"`, the imported CSS will be included automatically. You do not need
* to pass those styles in here. This is for any additional styles not in the imported CSS.
*
* See https://wxt.dev/entrypoints/content-scripts.html#ui for more info.
* See https://wxt.dev/guide/content-script-ui.html for more info.
*/
css?: string;
};
@@ -0,0 +1,24 @@
import { browser } from '~/browser';
export class WxtLocationChangeEvent extends Event {
static EVENT_NAME = getUniqueEventName('wxt:locationchange');
constructor(
readonly newUrl: URL,
readonly oldUrl: URL,
) {
super(WxtLocationChangeEvent.EVENT_NAME, {});
}
}
/**
* Returns an event name unique to the extension and content script that's running.
*/
export function getUniqueEventName(eventName: string): string {
// During the build process, __ENTRYPOINT__ is not defined when importing entrypoints to get their
// metadata.
const entrypointName =
typeof __ENTRYPOINT__ === 'undefined' ? 'build' : __ENTRYPOINT__;
return `${browser.runtime.id}:${entrypointName}:${eventName}`;
}
-1
View File
@@ -1,4 +1,3 @@
export * from './content-script-context';
export * from './content-script-ui';
export * from './content-script-iframe';
export * from './define-content-script';
@@ -0,0 +1,30 @@
import { ContentScriptContext } from '.';
import { WxtLocationChangeEvent } from './custom-events';
/**
* Create a util that watches for URL changes, dispatching the custom event when detected. Stops
* watching when content script is invalidated.
*/
export function createLocationWatcher(ctx: ContentScriptContext) {
let interval: number | undefined;
let oldUrl: URL;
return {
/**
* Ensure the location watcher is actively looking for URL changes. If it's already watching,
* this is a noop.
*/
run() {
if (interval != null) return;
oldUrl = new URL(location.href);
interval = ctx.setInterval(() => {
let newUrl = new URL(location.href);
if (newUrl.href !== oldUrl.href) {
window.dispatchEvent(new WxtLocationChangeEvent(newUrl, oldUrl));
oldUrl = newUrl;
}
}, 1e3);
},
};
}
+11 -1
View File
@@ -1,5 +1,15 @@
/**
* Any runtime APIs that use the web extension APIs.
*
* @module wxt/client
*/
export * from './define-background';
export * from './content-scripts';
export {
ContentScriptOverlayAlignment,
ContentScriptAppendMode,
ContentScriptPositioningOptions,
ContentScriptAnchoredOptions,
ContentScriptInlinePositioningOptions,
ContentScriptOverlayPositioningOptions,
ContentScriptModalPositioningOptions,
} from './utils/content-script-ui';
+42 -24
View File
@@ -4,6 +4,9 @@ export type ContentScriptOverlayAlignment =
| 'bottom-left'
| 'bottom-right';
/**
* ![Visualization of different append modes](https://wxt.dev/content-script-ui-append.png)
*/
export type ContentScriptAppendMode =
| 'last'
| 'first'
@@ -49,31 +52,46 @@ export function mountContentScriptUiRoot(
}
}
export interface ContentScriptInlinePositioningOptions {
type: 'inline';
}
export interface ContentScriptOverlayPositioningOptions {
type: 'overlay';
/**
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
* content.
*/
zIndex?: number;
/**
* When using `type: "overlay"`, the mounted element is 0px by 0px in size. Alignment specifies
* which corner is aligned with that 0x0 pixel space.
*
* ![Visualization of alignment options](https://wxt.dev/content-script-ui-alignment.png)
*
* @default "top-left"
*/
alignment?: ContentScriptOverlayAlignment;
}
export interface ContentScriptModalPositioningOptions {
type: 'modal';
/**
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
* content.
*/
zIndex?: number;
}
/**
* Choose between `"inline"`, `"overlay"`, or `"modal" `types.
*
* ![Visualization of different types](https://wxt.dev/content-script-ui-type.png)
*/
export type ContentScriptPositioningOptions =
| { type: 'inline' }
| {
type: 'overlay';
/**
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
* content.
*/
zIndex?: number;
/**
* When using `type: "overlay"`, the mounted element is 0px by 0px in size. Alignment specifies
* which corner is aligned with that 0x0 pixel space.
*
* @default "top-left"
*/
alignment?: ContentScriptOverlayAlignment;
}
| {
type: 'modal';
/**
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
* content.
*/
zIndex?: number;
};
| ContentScriptInlinePositioningOptions
| ContentScriptOverlayPositioningOptions
| ContentScriptModalPositioningOptions;
export function applyContentScriptUiPosition(
root: HTMLElement,
+254
View File
@@ -0,0 +1,254 @@
import type * as vite from 'vite';
import {
BuildStepOutput,
Entrypoint,
InlineConfig,
InternalConfig,
UserConfig,
VirtualEntrypointType,
WxtBuilder,
WxtBuilderServer,
} from '~/types';
import * as wxtPlugins from './plugins';
import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
export async function craeteViteBuilder(
inlineConfig: InlineConfig,
userConfig: UserConfig,
wxtConfig: Omit<InternalConfig, 'builder'>,
): Promise<WxtBuilder> {
const vite = await import('vite');
/**
* Returns the base vite config shared by all builds based on the inline and user config.
*/
const getBaseConfig = async () => {
const resolvedInlineConfig =
(await inlineConfig.vite?.(wxtConfig.env)) ?? {};
const resolvedUserConfig = (await userConfig.vite?.(wxtConfig.env)) ?? {};
const config: vite.InlineConfig = vite.mergeConfig(
resolvedUserConfig,
resolvedInlineConfig,
);
config.root = wxtConfig.root;
config.configFile = false;
config.logLevel = 'warn';
config.mode = wxtConfig.mode;
config.build ??= {};
config.build.outDir = wxtConfig.outDir;
config.build.emptyOutDir = false;
config.plugins ??= [];
config.plugins.push(
wxtPlugins.download(wxtConfig),
wxtPlugins.devHtmlPrerender(wxtConfig),
wxtPlugins.unimport(wxtConfig),
wxtPlugins.virtualEntrypoint('background', wxtConfig),
wxtPlugins.virtualEntrypoint('content-script-isolated-world', wxtConfig),
wxtPlugins.virtualEntrypoint('content-script-main-world', wxtConfig),
wxtPlugins.virtualEntrypoint('unlisted-script', wxtConfig),
wxtPlugins.devServerGlobals(wxtConfig),
wxtPlugins.tsconfigPaths(wxtConfig),
wxtPlugins.noopBackground(),
wxtPlugins.globals(wxtConfig),
wxtPlugins.excludeBrowserPolyfill(wxtConfig),
);
if (wxtConfig.analysis.enabled) {
config.plugins.push(wxtPlugins.bundleAnalysis());
}
return config;
};
/**
* Return the basic config for building an entrypoint in [lib mode](https://vitejs.dev/guide/build.html#library-mode).
*/
const getLibModeConfig = (entrypoint: Entrypoint): vite.InlineConfig => {
let virtualEntrypointType: VirtualEntrypointType | undefined;
switch (entrypoint.type) {
case 'background':
case 'unlisted-script':
virtualEntrypointType = entrypoint.type;
break;
case 'content-script':
virtualEntrypointType =
entrypoint.options.world === 'MAIN'
? 'content-script-main-world'
: 'content-script-isolated-world';
break;
}
const entry = virtualEntrypointType
? `virtual:wxt-${virtualEntrypointType}?${entrypoint.inputPath}`
: entrypoint.inputPath;
const plugins: NonNullable<vite.UserConfig['plugins']> = [
wxtPlugins.entrypointGroupGlobals(entrypoint),
];
if (
entrypoint.type === 'content-script-style' ||
entrypoint.type === 'unlisted-style'
) {
plugins.push(wxtPlugins.cssEntrypoints(entrypoint, wxtConfig));
}
const libMode: vite.UserConfig = {
mode: wxtConfig.mode,
plugins,
build: {
lib: {
entry,
formats: ['iife'],
name: '_',
fileName: entrypoint.name,
},
rollupOptions: {
output: {
// There's only a single output for this build, so we use the desired bundle path for the
// entry output (like "content-scripts/overlay.js")
entryFileNames: getEntrypointBundlePath(
entrypoint,
wxtConfig.outDir,
'.js',
),
// Output content script CSS to `content-scripts/`, but all other scripts are written to
// `assets/`.
assetFileNames: ({ name }) => {
if (
entrypoint.type === 'content-script' &&
name?.endsWith('css')
) {
return `content-scripts/${entrypoint.name}.[ext]`;
} else {
return `assets/${entrypoint.name}.[ext]`;
}
},
},
},
},
define: {
// See https://github.com/aklinker1/vite-plugin-web-extension/issues/96
'process.env.NODE_ENV': JSON.stringify(wxtConfig.mode),
},
};
return libMode;
};
/**
* Return the basic config for building multiple entrypoints in [multi-page mode](https://vitejs.dev/guide/build.html#multi-page-app).
*/
const getMultiPageConfig = (entrypoints: Entrypoint[]): vite.InlineConfig => {
return {
mode: wxtConfig.mode,
plugins: [
wxtPlugins.multipageMove(entrypoints, wxtConfig),
wxtPlugins.entrypointGroupGlobals(entrypoints),
],
build: {
rollupOptions: {
input: entrypoints.reduce<Record<string, string>>((input, entry) => {
input[entry.name] = entry.inputPath;
return input;
}, {}),
output: {
// Include a hash to prevent conflicts
chunkFileNames: 'chunks/[name]-[hash].js',
// Include a hash to prevent conflicts
entryFileNames: 'chunks/[name]-[hash].js',
// We can't control the "name", so we need a hash to prevent conflicts
assetFileNames: 'assets/[name]-[hash].[ext]',
},
},
},
};
};
/**
* Return the basic config for building a sinlge CSS entrypoint in [multi-page mode](https://vitejs.dev/guide/build.html#multi-page-app).
*/
const getCssConfig = (entrypoint: Entrypoint): vite.InlineConfig => {
return {
mode: wxtConfig.mode,
plugins: [wxtPlugins.entrypointGroupGlobals(entrypoint)],
build: {
rollupOptions: {
input: {
[entrypoint.name]: entrypoint.inputPath,
},
output: {
assetFileNames: () => {
if (entrypoint.type === 'content-script-style') {
return `content-scripts/${entrypoint.name}.[ext]`;
} else {
return `assets/${entrypoint.name}.[ext]`;
}
},
},
},
},
};
};
return {
name: 'Vite',
version: vite.version,
async build(group) {
let entryConfig;
if (Array.isArray(group)) entryConfig = getMultiPageConfig(group);
else if (group.inputPath.endsWith('.css'))
entryConfig = getCssConfig(group);
else entryConfig = getLibModeConfig(group);
const buildConfig = vite.mergeConfig(await getBaseConfig(), entryConfig);
const result = await vite.build(buildConfig);
return {
entrypoints: group,
chunks: getBuildOutputChunks(result),
};
},
async createServer(info) {
const serverConfig: vite.InlineConfig = {
server: {
port: info.port,
strictPort: true,
host: info.hostname,
origin: info.origin,
},
};
const baseConfig = await getBaseConfig();
const viteServer = await vite.createServer(
vite.mergeConfig(baseConfig, serverConfig),
);
const server: WxtBuilderServer = {
async listen() {
await viteServer.listen(info.port);
},
transformHtml(...args) {
return viteServer.transformIndexHtml(...args);
},
ws: {
send(message, payload) {
return viteServer.ws.send(message, payload);
},
on(message, cb) {
viteServer.ws.on(message, cb);
},
},
watcher: viteServer.watcher,
};
return server;
},
};
}
function getBuildOutputChunks(
result: Awaited<ReturnType<typeof vite.build>>,
): BuildStepOutput['chunks'] {
if ('on' in result) throw Error('wxt does not support vite watch mode.');
if (Array.isArray(result)) return result.flatMap(({ output }) => output);
return result.output;
}
@@ -1,4 +1,4 @@
import * as vite from 'vite';
import type * as vite from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
let increment = 0;
@@ -1,4 +1,4 @@
import * as vite from 'vite';
import type * as vite from 'vite';
import { Entrypoint, InternalConfig } from '~/types';
import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
@@ -14,7 +14,7 @@ import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
*/
export function cssEntrypoints(
entrypoint: Entrypoint,
config: InternalConfig,
config: Omit<InternalConfig, 'builder'>,
): vite.Plugin {
return {
name: 'wxt:css-entrypoint',
@@ -1,8 +1,8 @@
import * as vite from 'vite';
import type * as vite from 'vite';
import { InternalConfig } from '~/types';
import { getEntrypointName } from '~/core/utils/entrypoints';
import { parseHTML } from 'linkedom';
import { dirname, isAbsolute, relative, resolve } from 'path';
import { dirname, isAbsolute, relative, resolve } from 'node:path';
// Cache the preamble script for all devHtmlPrerender plugins, not just one
let reactRefreshPreamble = '';
@@ -10,7 +10,9 @@ let reactRefreshPreamble = '';
/**
* Pre-renders the HTML entrypoints when building the extension to connect to the dev server.
*/
export function devHtmlPrerender(config: InternalConfig): vite.PluginOption {
export function devHtmlPrerender(
config: Omit<InternalConfig, 'builder'>,
): vite.PluginOption {
const htmlReloadId = '@wxt/reload-html';
const resolvedHtmlReloadId = resolve(
config.root,
@@ -86,11 +88,7 @@ export function devHtmlPrerender(config: InternalConfig): vite.PluginOption {
const originalUrl = `${server.origin}${ctx.path}`;
const name = getEntrypointName(config.entrypointsDir, ctx.filename);
const url = `${server.origin}/${name}.html`;
const serverHtml = await server.transformIndexHtml(
url,
html,
originalUrl,
);
const serverHtml = await server.transformHtml(url, html, originalUrl);
const { document } = parseHTML(serverHtml);
// React pages include a preamble as an unsafe-inline type="module" script to enable fast refresh, as shown here:
@@ -4,20 +4,19 @@ import { InternalConfig } from '~/types';
/**
* Defines global constants about the dev server. Helps scripts connect to the server's web socket.
*/
export function devServerGlobals(internalConfig: InternalConfig): Plugin {
export function devServerGlobals(
config: Omit<InternalConfig, 'builder'>,
): Plugin {
return {
name: 'wxt:dev-server-globals',
config() {
if (internalConfig.server == null || internalConfig.command == 'build')
return;
if (config.server == null || config.command == 'build') return;
return {
define: {
__DEV_SERVER_PROTOCOL__: JSON.stringify('ws:'),
__DEV_SERVER_HOSTNAME__: JSON.stringify(
internalConfig.server.hostname,
),
__DEV_SERVER_PORT__: JSON.stringify(internalConfig.server.port),
__DEV_SERVER_HOSTNAME__: JSON.stringify(config.server.hostname),
__DEV_SERVER_PORT__: JSON.stringify(config.server.port),
},
};
},
@@ -9,7 +9,7 @@ import { fetchCached } from '~/core/utils/network';
* @example
* import "url:https://google-tagmanager.com/gtag?id=XYZ";
*/
export function download(config: InternalConfig): Plugin {
export function download(config: Omit<InternalConfig, 'builder'>): Plugin {
return {
name: 'wxt:download',
resolveId(id) {
@@ -0,0 +1,24 @@
import type * as vite from 'vite';
import { EntrypointGroup } from '~/types';
import { getEntrypointGlobals } from '~/core/utils/globals';
/**
* Define a set of global variables specific to an entrypoint.
*/
export function entrypointGroupGlobals(
entrypointGroup: EntrypointGroup,
): vite.PluginOption {
return {
name: 'wxt:entrypoint-group-globals',
config() {
const define: vite.InlineConfig['define'] = {};
let name = Array.isArray(entrypointGroup) ? 'html' : entrypointGroup.name;
for (const global of getEntrypointGlobals(name)) {
define[global.name] = JSON.stringify(global.value);
}
return {
define,
};
},
};
}
@@ -1,12 +1,14 @@
import { InternalConfig } from '~/types';
import * as vite from 'vite';
import type * as vite from 'vite';
/**
* Apply the experimental config for disabling the polyfill. It works by aliasing the
* `webextension-polyfill` module to a virtual module and exporting the `chrome` global from the
* virtual module.
*/
export function excludeBrowserPolyfill(config: InternalConfig): vite.Plugin {
export function excludeBrowserPolyfill(
config: Omit<InternalConfig, 'builder'>,
): vite.Plugin {
const virtualId = 'virtual:wxt-webextension-polyfill-disabled';
return {
@@ -1,8 +1,10 @@
import * as vite from 'vite';
import type * as vite from 'vite';
import { InternalConfig } from '~/types';
import { getGlobals } from '../utils/globals';
import { getGlobals } from '~/core/utils/globals';
export function globals(config: InternalConfig): vite.PluginOption {
export function globals(
config: Omit<InternalConfig, 'builder'>,
): vite.PluginOption {
return {
name: 'wxt:globals',
config() {
@@ -9,6 +9,6 @@ export * from './noopBackground';
export * from './cssEntrypoints';
export * from './bundleAnalysis';
export * from './globals';
export * from './webextensionPolyfillAlias';
export * from './webextensionPolyfillInlineDeps';
export * from './webextensionPolyfillMock';
export * from './excludeBrowserPolyfill';
export * from './entrypointGroupGlobals';
@@ -1,6 +1,6 @@
import * as vite from 'vite';
import type * as vite from 'vite';
import { Entrypoint, InternalConfig } from '~/types';
import { dirname, extname, resolve } from 'node:path';
import { dirname, extname, resolve, join } from 'node:path';
import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
import fs, { ensureDir } from 'fs-extra';
import { normalizePath } from '~/core/utils/paths';
@@ -20,7 +20,7 @@ import { normalizePath } from '~/core/utils/paths';
*/
export function multipageMove(
entrypoints: Entrypoint[],
config: InternalConfig,
config: Omit<InternalConfig, 'builder'>,
): vite.Plugin {
return {
name: 'wxt:multipage-move',
@@ -67,6 +67,30 @@ export function multipageMove(
delete bundle[oldBundlePath];
bundle[newBundlePath] = renamedChunk;
}
// Remove directories that were created
// TODO: Optimize and only delete old path directories
removeEmptyDirs(config.outDir);
},
};
}
/**
* Recursively remove all directories that are empty/
*/
export async function removeEmptyDirs(dir: string): Promise<void> {
const files = await fs.readdir(dir);
for (const file of files) {
const filePath = join(dir, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
await removeEmptyDirs(filePath);
}
}
try {
await fs.rmdir(dir);
} catch {
// noop on failure - this means the directory was not empty.
}
}
@@ -1,4 +1,5 @@
import { Plugin } from 'vite';
import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '~/core/utils/constants';
/**
* In dev mode, if there's not a background script listed, we need to add one.
@@ -20,5 +21,3 @@ export function noopBackground(): Plugin {
},
};
}
export const VIRTUAL_NOOP_BACKGROUND_MODULE_ID = 'virtual:user-background';
@@ -1,7 +1,9 @@
import { InternalConfig } from '~/types';
import * as vite from 'vite';
import type * as vite from 'vite';
export function tsconfigPaths(config: InternalConfig): vite.Plugin {
export function tsconfigPaths(
config: Omit<InternalConfig, 'builder'>,
): vite.Plugin {
return {
name: 'wxt:aliases',
async config() {
@@ -1,22 +1,24 @@
import { createUnimport } from 'unimport';
import { InternalConfig } from '~/types';
import { getUnimportOptions } from '~/core/utils/unimport';
import * as vite from 'vite';
import type * as vite from 'vite';
import { extname } from 'path';
const ENABLED_EXTENSIONS: Record<string, boolean | undefined> = {
'.js': true,
'.jsx': true,
'.ts': true,
'.tsx': true,
'.vue': true,
'.svelte': true,
};
const ENABLED_EXTENSIONS = new Set([
'.js',
'.jsx',
'.ts',
'.tsx',
'.vue',
'.svelte',
]);
/**
* Inject any global imports defined by unimport
*/
export function unimport(config: InternalConfig): vite.PluginOption {
export function unimport(
config: Omit<InternalConfig, 'builder'>,
): vite.PluginOption {
const options = getUnimportOptions(config);
if (options === false) return [];
@@ -28,8 +30,13 @@ export function unimport(config: InternalConfig): vite.PluginOption {
await unimport.scanImportsFromDir(undefined, { cwd: config.srcDir });
},
async transform(code, id) {
const ext = extname(id);
if (ENABLED_EXTENSIONS[ext]) return unimport.injectImports(code, id);
// Don't transform dependencies
if (id.includes('node_modules')) return;
// Don't transform non-js files
if (!ENABLED_EXTENSIONS.has(extname(id))) return;
return unimport.injectImports(code, id);
},
};
}
@@ -1,5 +1,5 @@
import { Plugin } from 'vite';
import { Entrypoint, InternalConfig } from '~/types';
import { InternalConfig, VirtualEntrypointType } from '~/types';
import fs from 'fs-extra';
import { resolve } from 'path';
import { normalizePath } from '~/core/utils/paths';
@@ -8,8 +8,8 @@ import { normalizePath } from '~/core/utils/paths';
* Wraps a user's entrypoint with a vitual version with additional logic.
*/
export function virtualEntrypoint(
type: Entrypoint['type'],
config: InternalConfig,
type: VirtualEntrypointType,
config: Omit<InternalConfig, 'builder'>,
): Plugin {
const virtualId = `virtual:wxt-${type}?`;
const resolvedVirtualId = `\0${virtualId}`;
@@ -0,0 +1,41 @@
import path from 'node:path';
import type * as vite from 'vite';
import { InternalConfig } from '~/types';
/**
* Mock `webextension-polyfill` by inlining all dependencies that import it and adding a custom
* alias so that Vite resolves to a mocked version of the module.
*
* There are two ways to mark a module as inline:
* 1. Use partial file paths ("wxt/dist/browser.js") in the `test.server.deps.inline` option.
* 2. Use module names ("wxt" or "@webext-core/messaging") in the `ssr.noExternalize` option.
*
* This plugin uses the second approach since it's a little more intuative to understand.
*
* TODO: Detect non-wxt dependencies (like `@webext-core/*`) that import `webextension-polyfill` via
* `npm list` and inline them automatically.
*/
export function webextensionPolyfillMock(
config: Omit<InternalConfig, 'builder'>,
): vite.PluginOption {
return {
name: 'wxt:testing-inline-deps',
config() {
return {
resolve: {
alias: {
// Alias to use a mocked version of the polyfill
'webextension-polyfill': path.resolve(
config.root,
'node_modules/wxt/dist/virtual/mock-browser',
),
},
},
ssr: {
// Inline all WXT modules
noExternal: ['wxt'],
},
};
},
};
}
+96 -114
View File
@@ -3,10 +3,9 @@ import {
EntrypointGroup,
InlineConfig,
InternalConfig,
ServerInfo,
WxtDevServer,
} from '~/types';
import * as vite from 'vite';
import type { Scripting } from 'webextension-polyfill';
import {
getEntrypointBundlePath,
getEntrypointOutputFile,
@@ -38,35 +37,103 @@ import { relative } from 'node:path';
* await server.start();
*/
export async function createServer(
config?: InlineConfig,
inlineConfig?: InlineConfig,
): Promise<WxtDevServer> {
const serverInfo = await getServerInfo();
const getLatestInternalConfig = async () => {
return getInternalConfig(
{
...config,
vite: () => serverInfo.viteServerConfig,
},
'serve',
);
const port = await getPort();
const hostname = 'localhost';
const origin = `http://${hostname}:${port}`;
const serverInfo: ServerInfo = {
port,
hostname,
origin,
};
let internalConfig = await getLatestInternalConfig();
const server = await setupServer(serverInfo, internalConfig);
internalConfig.server = server;
// 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
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);
},
transformHtml(url, html, originalUrl) {
return builderServer.transformHtml(url, html, originalUrl);
},
reloadContentScript(contentScript) {
server.ws.send('wxt:reload-content-script', contentScript);
},
reloadPage(path) {
server.ws.send('wxt:reload-page', path);
},
reloadExtension() {
server.ws.send('wxt:reload-extension');
},
};
const getLatestConfig = () =>
getInternalConfig(inlineConfig ?? {}, 'serve', server);
let config = await getLatestConfig();
const [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', () => {
reloadContentScripts(server.currentOutput.steps, config, server);
});
// Listen for file changes and reload different parts of the extension accordingly
const reloadOnChange = createFileReloader({
server,
getLatestConfig,
updateConfig(newConfig) {
config = newConfig;
},
});
server.watcher.on('all', reloadOnChange);
return server;
}
async function getPort(): Promise<number> {
const { default: getPort, portNumbers } = await import('get-port');
return await getPort({ port: portNumbers(3000, 3010) });
}
/**
* Returns a function responsible for reloading different parts of the extension when a file
* changes.
*/
function createFileReloader(options: {
server: WxtDevServer;
getLatestConfig: () => Promise<InternalConfig>;
updateConfig: (config: InternalConfig) => void;
}) {
const { server, getLatestConfig, updateConfig } = options;
const fileChangedMutex = new Mutex();
const changeQueue: Array<[string, string]> = [];
server.ws.on('wxt:background-initialized', () => {
// Register content scripts for the first time since they're not listed in the manifest
reloadContentScripts(server.currentOutput.steps, internalConfig, server);
});
return async (event: string, path: string) => {
const config = await getLatestConfig();
updateConfig(config);
server.watcher.on('all', async (event, path, _stats) => {
// Here, "path" is a non-normalized path (ie: C:\\users\\... instead of C:/users/...)
if (path.startsWith(internalConfig.outBaseDir)) return;
if (path.startsWith(config.outBaseDir)) return;
changeQueue.push([event, path]);
await fileChangedMutex.runExclusive(async () => {
@@ -77,25 +144,23 @@ export async function createServer(
if (changes.type === 'no-change') return;
// Log the entrypoints that were effected
internalConfig.logger.info(
config.logger.info(
`Changed: ${Array.from(new Set(fileChanges.map((change) => change[1])))
.map((file) => pc.dim(relative(internalConfig.root, file)))
.map((file) => pc.dim(relative(config.root, file)))
.join(', ')}`,
);
const rebuiltNames = changes.rebuildGroups
.flat()
.map((entry) => {
return pc.cyan(
relative(internalConfig.outDir, getEntrypointOutputFile(entry, '')),
relative(config.outDir, getEntrypointOutputFile(entry, '')),
);
})
.join(pc.dim(', '));
// Get latest config and Rebuild groups with changes
internalConfig = await getLatestInternalConfig();
internalConfig.server = server;
// Rebuild entrypoints on change
const { output: newOutput } = await rebuild(
internalConfig,
config,
// TODO: this excludes new entrypoints, so they're not built until the dev command is restarted
changes.rebuildGroups,
changes.cachedOutput,
@@ -108,91 +173,15 @@ export async function createServer(
server.reloadExtension();
break;
case 'html-reload':
reloadHtmlPages(changes.rebuildGroups, server, internalConfig);
reloadHtmlPages(changes.rebuildGroups, server, config);
break;
case 'content-script-reload':
reloadContentScripts(changes.changedSteps, internalConfig, server);
reloadContentScripts(changes.changedSteps, config, server);
break;
}
consola.success(`Reloaded: ${rebuiltNames}`);
});
});
return server;
}
async function getServerInfo(): Promise<ServerInfo> {
const { default: getPort, portNumbers } = await import('get-port');
const port = await getPort({ port: portNumbers(3000, 3010) });
const hostname = 'localhost';
const origin = `http://${hostname}:${port}`;
const serverConfig: vite.InlineConfig = {
server: {
origin,
},
};
return {
port,
hostname,
origin,
viteServerConfig: serverConfig,
};
}
async function setupServer(
serverInfo: ServerInfo,
config: InternalConfig,
): Promise<WxtDevServer> {
const runner = await createExtensionRunner(config);
const viteServer = await vite.createServer(
vite.mergeConfig(serverInfo, await config.vite(config.env)),
);
const start = async () => {
await viteServer.listen(server.port);
config.logger.success(`Started dev server @ ${serverInfo.origin}`);
server.currentOutput = await internalBuild(config);
await runner.openBrowser(config);
};
const reloadExtension = () => {
viteServer.ws.send('wxt:reload-extension');
};
const reloadPage = (path: string) => {
// Can't use Vite's built-in "full-reload" event because it doesn't like our paths, it expects
// paths ending in "/index.html"
viteServer.ws.send('wxt:reload-page', path);
};
const reloadContentScript = (
contentScript: Omit<Scripting.RegisteredContentScript, 'id'>,
) => {
viteServer.ws.send('wxt:reload-content-script', contentScript);
};
const server: WxtDevServer = {
...viteServer,
start,
currentOutput: {
manifest: {
manifest_version: 3,
name: '',
version: '',
},
publicAssets: [],
steps: [],
},
port: serverInfo.port,
hostname: serverInfo.hostname,
origin: serverInfo.origin,
reloadExtension,
reloadPage,
reloadContentScript,
};
return server;
}
/**
@@ -244,10 +233,3 @@ function reloadHtmlPages(
server.reloadPage(path);
});
}
interface ServerInfo {
port: number;
hostname: string;
origin: string;
viteServerConfig: vite.InlineConfig;
}
-8
View File
@@ -150,14 +150,6 @@ async function cloneProject({
consola.warn('Failed to move _gitignore to .gitignore:', err),
);
// 3. Add .npmrc for pnpm
if (packageManager === 'pnpm') {
await fs.writeFile(
path.join(directory, '.npmrc'),
'shamefully-hoist=true\n',
);
}
spinner.succeed();
} catch (err) {
spinner.fail();
@@ -11,7 +11,7 @@ describe('Content Script Utils', () => {
);
expect(hash).toMatchInlineSnapshot(
'"[[\\"all_frames\\",false],[\\"exclude_globs\\",[]],[\\"exclude_matches\\",[]],[\\"include_globs\\",[]],[\\"match_about_blank\\",false],[\\"match_origin_as_fallback\\",false],[\\"matches\\",[]],[\\"run_at\\",\\"document_idle\\"],[\\"world\\",\\"ISOLATED\\"]]"',
`"[["all_frames",false],["exclude_globs",[]],["exclude_matches",[]],["include_globs",[]],["match_about_blank",false],["match_origin_as_fallback",false],["matches",[]],["run_at","document_idle"],["world","ISOLATED"]]"`,
);
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { normalizePath } from '../paths';
describe('Path Utils', () => {
describe('normalizePath', () => {
it.each([
// Relative paths
['../test.sh', '../test.sh'],
['..\\test.sh', '../test.sh'],
['test.png', 'test.png'],
// Absolute paths
['C:\\\\path\\to\\file', 'C:/path/to/file'],
['/path/to/file', '/path/to/file'],
// Strip trailing slash
['C:\\\\path\\to\\folder\\', 'C:/path/to/folder'],
['/path/to/folder/', '/path/to/folder'],
// Dedupe slashes
['path\\\\\\file', 'path/file'],
['path//file', 'path/file'],
])('should normalize "%s" to "%s"', (input, expected) => {
expect(normalizePath(input)).toBe(expected);
});
});
});
@@ -1,4 +1,4 @@
import { defineBackground } from '~/client';
import { defineBackground } from '~/sandbox';
export default defineBackground({
main() {},
@@ -1,4 +1,4 @@
import { defineContentScript } from '~/client';
import { defineContentScript } from '~/sandbox';
export default defineContentScript({
matches: ['<all_urls>'],
@@ -1,4 +1,4 @@
import { defineBackground } from '~/client';
import { defineBackground } from '~/sandbox';
export const a = {};
@@ -8,8 +8,8 @@ import {
fakeManifest,
fakeOptionsEntrypoint,
fakePopupEntrypoint,
fakeRollupOutputAsset,
fakeRollupOutputChunk,
fakeOutputAsset,
fakeOutputChunk,
} from '~/core/utils/testing/fake-objects';
import { BuildOutput, BuildStepOutput } from '~/types';
@@ -32,15 +32,11 @@ describe('Detect Dev Changes', () => {
steps: [
{
entrypoints: fakeContentScriptEntrypoint(),
chunks: [fakeRollupOutputChunk(), fakeRollupOutputChunk()],
chunks: [fakeOutputChunk(), fakeOutputChunk()],
},
{
entrypoints: fakeContentScriptEntrypoint(),
chunks: [
fakeRollupOutputChunk(),
fakeRollupOutputChunk(),
fakeRollupOutputChunk(),
],
chunks: [fakeOutputChunk(), fakeOutputChunk(), fakeOutputChunk()],
},
],
};
@@ -57,10 +53,10 @@ describe('Detect Dev Changes', () => {
'unknown',
'/root/src/public/image.svg',
];
const asset1 = fakeRollupOutputAsset({
const asset1 = fakeOutputAsset({
fileName: 'image.svg',
});
const asset2 = fakeRollupOutputAsset({
const asset2 = fakeOutputAsset({
fileName: 'some-other-image.svg',
});
const currentOutput: BuildOutput = {
@@ -96,7 +92,7 @@ describe('Detect Dev Changes', () => {
const step1: BuildStepOutput = {
entrypoints: contentScript,
chunks: [
fakeRollupOutputChunk({
fakeOutputChunk({
moduleIds: [fakeFile(), fakeFile()],
}),
],
@@ -104,7 +100,7 @@ describe('Detect Dev Changes', () => {
const step2: BuildStepOutput = {
entrypoints: background,
chunks: [
fakeRollupOutputChunk({
fakeOutputChunk({
moduleIds: [fakeFile(), changedPath, fakeFile()],
}),
],
@@ -150,7 +146,7 @@ describe('Detect Dev Changes', () => {
const step1: BuildStepOutput = {
entrypoints: [htmlPage1, htmlPage2],
chunks: [
fakeRollupOutputChunk({
fakeOutputChunk({
moduleIds: [fakeFile(), changedPath],
}),
],
@@ -158,7 +154,7 @@ describe('Detect Dev Changes', () => {
const step2: BuildStepOutput = {
entrypoints: [htmlPage3],
chunks: [
fakeRollupOutputChunk({
fakeOutputChunk({
moduleIds: [fakeFile(), fakeFile(), fakeFile()],
}),
],
@@ -203,7 +199,7 @@ describe('Detect Dev Changes', () => {
const step1: BuildStepOutput = {
entrypoints: script1,
chunks: [
fakeRollupOutputChunk({
fakeOutputChunk({
moduleIds: [fakeFile(), changedPath],
}),
],
@@ -211,7 +207,7 @@ describe('Detect Dev Changes', () => {
const step2: BuildStepOutput = {
entrypoints: script2,
chunks: [
fakeRollupOutputChunk({
fakeOutputChunk({
moduleIds: [fakeFile(), fakeFile(), fakeFile()],
}),
],
@@ -219,7 +215,7 @@ describe('Detect Dev Changes', () => {
const step3: BuildStepOutput = {
entrypoints: script3,
chunks: [
fakeRollupOutputChunk({
fakeOutputChunk({
moduleIds: [changedPath, fakeFile(), fakeFile()],
}),
],
@@ -244,15 +244,15 @@ describe('findEntrypoints', () => {
);
it("should include a virtual background script so dev reloading works when there isn't a background entrypoint defined by the user", async () => {
globMock.mockResolvedValueOnce([]);
globMock.mockResolvedValueOnce(['popup.html']);
const entrypoints = await findEntrypoints({
...config,
command: 'serve',
});
expect(entrypoints).toHaveLength(1);
expect(entrypoints[0]).toEqual({
expect(entrypoints).toHaveLength(2);
expect(entrypoints).toContainEqual({
type: 'background',
inputPath: 'virtual:user-background',
name: 'background',
@@ -261,28 +261,24 @@ describe('findEntrypoints', () => {
});
});
it.each<[string, Omit<GenericEntrypoint, 'options'>]>([
[
'injected.ts',
{
type: 'unlisted-script',
name: 'injected',
inputPath: resolve(config.entrypointsDir, 'injected.ts'),
outputDir: config.outDir,
},
],
[
'injected/index.ts',
{
type: 'unlisted-script',
name: 'injected',
inputPath: resolve(config.entrypointsDir, 'injected/index.ts'),
outputDir: config.outDir,
},
],
it.each<string>([
'injected.ts',
'injected.tsx',
'injected.js',
'injected.jsx',
'injected/index.ts',
'injected/index.tsx',
'injected/index.js',
'injected/index.jsx',
])(
'should find and load unlisted-script entrypoint config from %s',
async (path, expected) => {
async (path) => {
const expected = {
type: 'unlisted-script',
name: 'injected',
inputPath: resolve(config.entrypointsDir, path),
outputDir: config.outDir,
};
const options: GenericEntrypoint['options'] = {};
globMock.mockResolvedValueOnce([path]);
importEntrypointFileMock.mockResolvedValue(options);
@@ -566,15 +562,35 @@ describe('findEntrypoints', () => {
});
it('should not allow multiple entrypoints with the same name', async () => {
globMock.mockResolvedValueOnce(['popup.html', 'popup/index.html']);
const expectedPaths = [
'src/entrypoints/popup.html',
'src/entrypoints/popup/index.html',
].map(unnormalizePath);
globMock.mockResolvedValueOnce([
'options/index.html',
'options/index.jsx',
'popup.html',
'popup/index.html',
'popup/index.ts',
'ui.html',
]);
await expect(() => findEntrypoints(config)).rejects.toThrowError(
'Multiple entrypoints with the name "popup" detected, but only one is allowed: ' +
expectedPaths.join(', '),
[
'Multiple entrypoints with the same name detected, only one entrypoint for each name is allowed.',
'',
'- options',
` - ${unnormalizePath('src/entrypoints/options/index.html')}`,
` - ${unnormalizePath('src/entrypoints/options/index.jsx')}`,
'- popup',
` - ${unnormalizePath('src/entrypoints/popup.html')}`,
` - ${unnormalizePath('src/entrypoints/popup/index.html')}`,
` - ${unnormalizePath('src/entrypoints/popup/index.ts')}`,
].join('\n'),
);
});
it('throw an error if there are no entrypoints', async () => {
globMock.mockResolvedValueOnce([]);
await expect(() => findEntrypoints(config)).rejects.toThrowError(
`No entrypoints found in ${unnormalizePath(config.entrypointsDir)}`,
);
});
+6 -152
View File
@@ -1,18 +1,12 @@
import * as vite from 'vite';
import {
BuildOutput,
BuildStepOutput,
Entrypoint,
EntrypointGroup,
InternalConfig,
} from '~/types';
import * as wxtPlugins from '~/core/vite-plugins';
import { removeEmptyDirs } from '~/core/utils/fs';
import { getPublicFiles } from '~/core/utils/fs';
import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
import fs from 'fs-extra';
import { dirname, resolve } from 'path';
import { getEntrypointGlobals } from '~/core/utils/globals';
import type { Ora } from 'ora';
import pc from 'picocolors';
@@ -24,155 +18,18 @@ export async function buildEntrypoints(
const steps: BuildStepOutput[] = [];
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
spinner.text =
pc.dim(`[${i + 1}/${groups.length}]`) +
` ${[group]
.flat()
.map((e) => e.name)
.join(pc.dim(', '))}`;
const step = Array.isArray(group)
? await buildMultipleEntrypoints(group, config)
: await buildSingleEntrypoint(group, config);
steps.push(step);
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 publicAssets = await copyPublicDirectory(config);
// Remove any empty directories from moving outputs around
await removeEmptyDirs(config.outDir);
return { publicAssets, steps };
}
/**
* Use Vite's lib mode + IIFE format to bundle the entrypoint to a single file.
*/
async function buildSingleEntrypoint(
entrypoint: Entrypoint,
config: InternalConfig,
): Promise<BuildStepOutput> {
// Should this entrypoint be wrapped by the vite-plugins/virtualEntrypoint plugin?
const isVirtual = [
'background',
'content-script',
'unlisted-script',
].includes(entrypoint.type);
const entry = isVirtual
? `virtual:wxt-${entrypoint.type}?${entrypoint.inputPath}`
: entrypoint.inputPath;
const plugins: NonNullable<vite.UserConfig['plugins']> = [];
if (
entrypoint.type === 'content-script-style' ||
entrypoint.type === 'unlisted-style'
) {
plugins.push(wxtPlugins.cssEntrypoints(entrypoint, config));
}
const libMode: vite.UserConfig = {
mode: config.mode,
plugins,
build: {
lib: {
entry,
formats: ['iife'],
name: '_',
fileName: entrypoint.name,
},
rollupOptions: {
output: {
// There's only a single output for this build, so we use the desired bundle path for the
// entry output (like "content-scripts/overlay.js")
entryFileNames: getEntrypointBundlePath(
entrypoint,
config.outDir,
'.js',
),
// Output content script CSS to `content-scripts/`, but all other scripts are written to
// `assets/`.
assetFileNames: ({ name }) => {
if (entrypoint.type === 'content-script' && name?.endsWith('css')) {
return `content-scripts/${entrypoint.name}.[ext]`;
} else {
return `assets/${entrypoint.name}.[ext]`;
}
},
},
},
},
define: {
// See https://github.com/aklinker1/vite-plugin-web-extension/issues/96
'process.env.NODE_ENV': JSON.stringify(config.mode),
},
};
for (const global of getEntrypointGlobals(config, entrypoint.name)) {
libMode.define![global.name] = JSON.stringify(global.value);
}
const entryConfig = vite.mergeConfig(
libMode,
await config.vite(config.env),
) as vite.InlineConfig;
const result = await vite.build(entryConfig);
return {
entrypoints: entrypoint,
chunks: getBuildOutputChunks(result),
};
}
/**
* Use Vite's multipage build to bundle all the entrypoints in a single step.
*/
async function buildMultipleEntrypoints(
entrypoints: Entrypoint[],
config: InternalConfig,
): Promise<BuildStepOutput> {
const multiPage: vite.UserConfig = {
mode: config.mode,
plugins: [wxtPlugins.multipageMove(entrypoints, config)],
build: {
rollupOptions: {
input: entrypoints.reduce<Record<string, string>>((input, entry) => {
input[entry.name] = entry.inputPath;
return input;
}, {}),
output: {
// Include a hash to prevent conflicts
chunkFileNames: 'chunks/[name]-[hash].js',
// Include a hash to prevent conflicts
entryFileNames: 'chunks/[name]-[hash].js',
// We can't control the "name", so we need a hash to prevent conflicts
assetFileNames: 'assets/[name]-[hash].[ext]',
},
},
},
define: {},
};
for (const global of getEntrypointGlobals(config, 'html')) {
multiPage.define![global.name] = JSON.stringify(global.value);
}
const entryConfig = vite.mergeConfig(
multiPage,
await config.vite(config.env),
) as vite.UserConfig;
const result = await vite.build(entryConfig);
return {
entrypoints,
chunks: getBuildOutputChunks(result),
};
}
function getBuildOutputChunks(
result: Awaited<ReturnType<typeof vite.build>>,
): BuildStepOutput['chunks'] {
if ('on' in result) throw Error('wxt does not support vite watch mode.');
if (Array.isArray(result)) return result.flatMap(({ output }) => output);
return result.output;
}
async function copyPublicDirectory(
config: InternalConfig,
): Promise<BuildOutput['publicAssets']> {
@@ -189,9 +46,6 @@ async function copyPublicDirectory(
publicAssets.push({
type: 'asset',
fileName: file,
name: file,
needsCodeReference: false,
source: await fs.readFile(srcPath),
});
}
@@ -1,5 +1,10 @@
import { BuildOutput, BuildStepOutput, EntrypointGroup } from '~/types';
import * as vite from 'vite';
import {
BuildOutput,
BuildStepOutput,
EntrypointGroup,
OutputAsset,
OutputFile,
} from '~/types';
import { every } from '~/core/utils/arrays';
import { normalizePath } from '~/core/utils/paths';
@@ -105,9 +110,7 @@ function findEffectedSteps(
const changes: DetectedChange[] = [];
const changedPath = normalizePath(changedFile[1]);
const isChunkEffected = (
chunk: vite.Rollup.OutputChunk | vite.Rollup.OutputAsset,
): boolean =>
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
// fileName is normalized, relative bundle path
(chunk.type === 'asset' && changedPath.endsWith(chunk.fileName)) ||
@@ -176,4 +179,4 @@ interface ContentScriptReload extends RebuildChange {
* directory asset that was changed. It doesn't know what type of change is required yet. Just an
* intermediate type.
*/
type DetectedChange = BuildStepOutput | vite.Rollup.OutputAsset;
type DetectedChange = BuildStepOutput | OutputAsset;
+104 -85
View File
@@ -22,7 +22,7 @@ import {
getEntrypointName,
resolvePerBrowserOption,
} from '~/core/utils/entrypoints';
import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '~/core/vite-plugins/noopBackground';
import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '~/core/utils/constants';
import { CSS_EXTENSIONS_PATTERN } from '~/core/utils/paths';
/**
@@ -31,56 +31,55 @@ import { CSS_EXTENSIONS_PATTERN } from '~/core/utils/paths';
export async function findEntrypoints(
config: InternalConfig,
): Promise<Entrypoint[]> {
const relativePaths = await glob('**/*', {
const relativePaths = await glob(Object.keys(PATH_GLOB_TO_TYPE_MAP), {
cwd: config.entrypointsDir,
});
// Ensure consistent output
relativePaths.sort();
const pathGlobs = Object.keys(PATH_GLOB_TO_TYPE_MAP);
let hasBackground = false;
// TODO: This parallelization is bad
const possibleEntrypoints: Array<Entrypoint | undefined> = await Promise.all(
relativePaths.map(async (relativePath) => {
const path = resolve(config.entrypointsDir, relativePath);
const matchingGlob = pathGlobs.find((glob) =>
minimatch(relativePath, glob),
);
if (matchingGlob == null) {
config.logger.warn(
`${relativePath} does not match any known entrypoint. Known entrypoints:\n${JSON.stringify(
PATH_GLOB_TO_TYPE_MAP,
null,
2,
)}`,
);
return;
}
const entrypointInfos: EntrypointInfo[] = relativePaths.reduce<
EntrypointInfo[]
>((results, relativePath) => {
const inputPath = resolve(config.entrypointsDir, relativePath);
const name = getEntrypointName(config.entrypointsDir, inputPath);
const matchingGlob = pathGlobs.find((glob) =>
minimatch(relativePath, glob),
);
if (matchingGlob) {
const type = PATH_GLOB_TO_TYPE_MAP[matchingGlob];
if (type === 'ignored') return;
results.push({ name, inputPath, type });
}
return results;
}, []);
// Validation
preventNoEntrypoints(config, entrypointInfos);
preventDuplicateEntrypointNames(config, entrypointInfos);
// Import entrypoints to get their config
let hasBackground = false;
const entrypoints: Entrypoint[] = await Promise.all(
entrypointInfos.map(async (info): Promise<Entrypoint> => {
const { type } = info;
switch (type) {
case 'popup':
return await getPopupEntrypoint(config, path);
return await getPopupEntrypoint(config, info);
case 'options':
return await getOptionsEntrypoint(config, path);
return await getOptionsEntrypoint(config, info);
case 'background':
hasBackground = true;
return await getBackgroundEntrypoint(config, path);
return await getBackgroundEntrypoint(config, info);
case 'content-script':
return await getContentScriptEntrypoint(config, path);
return await getContentScriptEntrypoint(config, info);
case 'unlisted-page':
return await getUnlistedPageEntrypoint(config, path);
return await getUnlistedPageEntrypoint(config, info);
case 'unlisted-script':
return await getUnlistedScriptEntrypoint(config, path);
return await getUnlistedScriptEntrypoint(config, info);
case 'content-script-style':
return {
...info,
type,
name: getEntrypointName(config.entrypointsDir, path),
inputPath: path,
outputDir: resolve(config.outDir, CONTENT_SCRIPT_OUT_DIR),
options: {
include: undefined,
@@ -89,9 +88,8 @@ export async function findEntrypoints(
};
default:
return {
...info,
type,
name: getEntrypointName(config.entrypointsDir, path),
inputPath: path,
outputDir: config.outDir,
options: {
include: undefined,
@@ -102,30 +100,13 @@ export async function findEntrypoints(
}),
);
const entrypoints = possibleEntrypoints.filter(
(entry) => !!entry,
) as Entrypoint[];
// Report duplicate entrypoint names
const existingNames: Record<string, Entrypoint | undefined> = {};
entrypoints.forEach((entrypoint) => {
const withSameName = existingNames[entrypoint.name];
if (withSameName) {
throw Error(
`Multiple entrypoints with the name "${
entrypoint.name
}" detected, but only one is allowed: ${[
relative(config.root, withSameName.inputPath),
relative(config.root, entrypoint.inputPath),
].join(', ')}`,
);
}
existingNames[entrypoint.name] = entrypoint;
});
if (config.command === 'serve' && !hasBackground) {
entrypoints.push(
await getBackgroundEntrypoint(config, VIRTUAL_NOOP_BACKGROUND_MODULE_ID),
await getBackgroundEntrypoint(config, {
inputPath: VIRTUAL_NOOP_BACKGROUND_MODULE_ID,
name: 'background',
type: 'background',
}),
);
}
@@ -151,6 +132,50 @@ export async function findEntrypoints(
return targetEntrypoints;
}
interface EntrypointInfo {
name: string;
inputPath: string;
type: Entrypoint['type'];
}
function preventDuplicateEntrypointNames(
config: InternalConfig,
files: EntrypointInfo[],
) {
const namesToPaths = files.reduce<Record<string, string[]>>(
(map, { name, inputPath }) => {
map[name] ??= [];
map[name].push(inputPath);
return map;
},
{},
);
const errorLines = Object.entries(namesToPaths).reduce<string[]>(
(lines, [name, absolutePaths]) => {
if (absolutePaths.length > 1) {
lines.push(`- ${name}`);
absolutePaths.forEach((absolutePath) => {
lines.push(` - ${relative(config.root, absolutePath)}`);
});
}
return lines;
},
[],
);
if (errorLines.length > 0) {
const errorContent = errorLines.join('\n');
throw Error(
`Multiple entrypoints with the same name detected, only one entrypoint for each name is allowed.\n\n${errorContent}`,
);
}
}
function preventNoEntrypoints(config: InternalConfig, files: EntrypointInfo[]) {
if (files.length === 0) {
throw Error(`No entrypoints found in ${config.entrypointsDir}`);
}
}
function getHtmlBaseOptions(document: Document): BaseEntrypointOptions {
const options: BaseEntrypointOptions = {};
@@ -177,9 +202,9 @@ function getHtmlBaseOptions(document: Document): BaseEntrypointOptions {
*/
async function getPopupEntrypoint(
config: InternalConfig,
path: string,
{ inputPath, name }: EntrypointInfo,
): Promise<PopupEntrypoint> {
const content = await fs.readFile(path, 'utf-8');
const content = await fs.readFile(inputPath, 'utf-8');
const { document } = parseHTML(content);
const options: PopupEntrypoint['options'] = getHtmlBaseOptions(document);
@@ -220,7 +245,7 @@ async function getPopupEntrypoint(
type: 'popup',
name: 'popup',
options,
inputPath: path,
inputPath,
outputDir: config.outDir,
};
}
@@ -231,9 +256,9 @@ async function getPopupEntrypoint(
*/
async function getOptionsEntrypoint(
config: InternalConfig,
path: string,
{ inputPath, name }: EntrypointInfo,
): Promise<OptionsEntrypoint> {
const content = await fs.readFile(path, 'utf-8');
const content = await fs.readFile(inputPath, 'utf-8');
const { document } = parseHTML(content);
const options: OptionsEntrypoint['options'] = getHtmlBaseOptions(document);
@@ -263,7 +288,7 @@ async function getOptionsEntrypoint(
type: 'options',
name: 'options',
options,
inputPath: path,
inputPath,
outputDir: config.outDir,
};
}
@@ -274,15 +299,15 @@ async function getOptionsEntrypoint(
*/
async function getUnlistedPageEntrypoint(
config: InternalConfig,
path: string,
{ inputPath, name }: EntrypointInfo,
): Promise<GenericEntrypoint> {
const content = await fs.readFile(path, 'utf-8');
const content = await fs.readFile(inputPath, 'utf-8');
const { document } = parseHTML(content);
return {
type: 'unlisted-page',
name: getEntrypointName(config.entrypointsDir, path),
inputPath: path,
name: getEntrypointName(config.entrypointsDir, inputPath),
inputPath,
outputDir: config.outDir,
options: getHtmlBaseOptions(document),
};
@@ -294,11 +319,10 @@ async function getUnlistedPageEntrypoint(
*/
async function getUnlistedScriptEntrypoint(
config: InternalConfig,
path: string,
{ inputPath, name }: EntrypointInfo,
): Promise<GenericEntrypoint> {
const name = getEntrypointName(config.entrypointsDir, path);
const defaultExport = await importEntrypointFile<UnlistedScriptDefinition>(
path,
inputPath,
config,
);
if (defaultExport == null) {
@@ -311,7 +335,7 @@ async function getUnlistedScriptEntrypoint(
return {
type: 'unlisted-script',
name,
inputPath: path,
inputPath,
outputDir: config.outDir,
options,
};
@@ -322,13 +346,12 @@ async function getUnlistedScriptEntrypoint(
*/
async function getBackgroundEntrypoint(
config: InternalConfig,
path: string,
{ inputPath, name }: EntrypointInfo,
): Promise<BackgroundEntrypoint> {
const name = 'background';
let options: Omit<BackgroundDefinition, 'main'> = {};
if (path !== VIRTUAL_NOOP_BACKGROUND_MODULE_ID) {
if (inputPath !== VIRTUAL_NOOP_BACKGROUND_MODULE_ID) {
const defaultExport = await importEntrypointFile<BackgroundDefinition>(
path,
inputPath,
config,
);
if (defaultExport == null) {
@@ -342,7 +365,7 @@ async function getBackgroundEntrypoint(
return {
type: 'background',
name,
inputPath: path,
inputPath,
outputDir: config.outDir,
options: {
...options,
@@ -357,11 +380,10 @@ async function getBackgroundEntrypoint(
*/
async function getContentScriptEntrypoint(
config: InternalConfig,
path: string,
{ inputPath, name }: EntrypointInfo,
): Promise<ContentScriptEntrypoint> {
const name = getEntrypointName(config.entrypointsDir, path);
const { main: _, ...options } =
await importEntrypointFile<ContentScriptDefinition>(path, config);
await importEntrypointFile<ContentScriptDefinition>(inputPath, config);
if (options == null) {
throw Error(
`${name}: Default export not found, did you forget to call "export default defineContentScript(...)"?`,
@@ -370,13 +392,13 @@ async function getContentScriptEntrypoint(
return {
type: 'content-script',
name,
inputPath: path,
inputPath,
outputDir: resolve(config.outDir, CONTENT_SCRIPT_OUT_DIR),
options,
};
}
const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type'] | 'ignored'> = {
const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type']> = {
'sandbox.html': 'sandbox',
'sandbox/index.html': 'sandbox',
'*.sandbox.html': 'sandbox',
@@ -420,13 +442,10 @@ const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type'] | 'ignored'> = {
'*.html': 'unlisted-page',
'*/index.html': 'unlisted-page',
'*.[jt]s': 'unlisted-script',
'*/index.ts': 'unlisted-script',
'*.[jt]s?(x)': 'unlisted-script',
'*/index.[jt]s?(x)': 'unlisted-script',
[`*.${CSS_EXTENSIONS_PATTERN}`]: 'unlisted-style',
[`*/index.${CSS_EXTENSIONS_PATTERN}`]: 'unlisted-style',
// Don't warn about any files in subdirectories, like CSS or JS entrypoints for HTML files or tests
'*/**': 'ignored',
};
const CONTENT_SCRIPT_OUT_DIR = 'content-scripts';
+1 -1
View File
@@ -156,7 +156,7 @@ async function writeGlobalsDeclarationFile(
config: InternalConfig,
): Promise<string> {
const filePath = resolve(config.typesDir, 'globals.d.ts');
const globals = [...getGlobals(config), ...getEntrypointGlobals(config, '')];
const globals = [...getGlobals(config), ...getEntrypointGlobals('')];
await writeFileIfDifferent(
filePath,
[
+32 -69
View File
@@ -6,14 +6,15 @@ import {
ConfigEnv,
UserManifestFn,
UserManifest,
WxtViteConfig,
ExtensionRunnerConfig,
WxtDevServer,
} from '~/types';
import path from 'node:path';
import * as vite from 'vite';
import { createFsCache } from '~/core/utils/cache';
import consola, { LogLevels } from 'consola';
import * as plugins from '~/core/vite-plugins';
import { craeteViteBuilder } from '~/core/builders/vite';
import defu from 'defu';
import { NullablyRequired } from '../types';
/**
* Given an inline config, discover the config file if necessary, merge the results, resolve any
@@ -25,6 +26,7 @@ import * as plugins from '~/core/vite-plugins';
export async function getInternalConfig(
inlineConfig: InlineConfig,
command: 'build' | 'serve',
server?: WxtDevServer,
): Promise<InternalConfig> {
// Load user config
@@ -35,6 +37,9 @@ export async function getInternalConfig(
name: 'wxt',
cwd: inlineConfig.root ?? process.cwd(),
rcFile: false,
jitiOptions: {
esmResolve: true,
},
});
userConfig = loadedConfig ?? {};
userConfigMetadata = metadata;
@@ -69,7 +74,7 @@ export async function getInternalConfig(
);
const publicDir = path.resolve(srcDir, mergedConfig.publicDir ?? 'public');
const typesDir = path.resolve(wxtDir, 'types');
const outBaseDir = path.resolve(root, '.output');
const outBaseDir = path.resolve(root, mergedConfig.outDir ?? '.output');
const outDir = path.resolve(outBaseDir, `${browser}-mv${manifestVersion}`);
const runnerConfig = await loadConfig<ExtensionRunnerConfig>({
@@ -91,7 +96,7 @@ export async function getInternalConfig(
}).map(([key, value]) => [key, path.resolve(root, value)]),
);
const finalConfig: InternalConfig = {
const finalConfig: Omit<InternalConfig, 'builder'> = {
browser,
command,
debug,
@@ -110,7 +115,6 @@ export async function getInternalConfig(
runnerConfig,
srcDir,
typesDir,
vite: () => ({}), // Real value added after this object is initialized.
wxtDir,
zip: resolveInternalZipConfig(root, mergedConfig),
transformManifest(manifest) {
@@ -127,12 +131,19 @@ export async function getInternalConfig(
includeBrowserPolyfill:
mergedConfig.experimental?.includeBrowserPolyfill ?? true,
},
server,
};
finalConfig.vite = (env) =>
resolveInternalViteConfig(env, mergedConfig, finalConfig);
const builder = await craeteViteBuilder(
inlineConfig,
userConfig,
finalConfig,
);
return finalConfig;
return {
...finalConfig,
builder,
};
}
async function resolveManifestConfig(
@@ -150,35 +161,27 @@ async function resolveManifestConfig(
function mergeInlineConfig(
inlineConfig: InlineConfig,
userConfig: UserConfig,
): InlineConfig {
): NullablyRequired<InlineConfig> {
let imports: InlineConfig['imports'];
if (inlineConfig.imports === false || userConfig.imports === false) {
imports = false;
} else if (userConfig.imports == null && inlineConfig.imports == null) {
imports = undefined;
} else {
imports = vite.mergeConfig(
userConfig.imports ?? {},
inlineConfig.imports ?? {},
);
imports = defu(inlineConfig.imports ?? {}, userConfig.imports ?? {});
}
const manifest: UserManifestFn = async (env) => {
const user = await resolveManifestConfig(env, userConfig.manifest);
const inline = await resolveManifestConfig(env, inlineConfig.manifest);
return vite.mergeConfig(user, inline);
return defu(inline, user);
};
const viteConfig = async (env: ConfigEnv): Promise<WxtViteConfig> => {
const user = await userConfig.vite?.(env);
const inline = await inlineConfig.vite?.(env);
return vite.mergeConfig(user ?? {}, inline ?? {});
};
const runner: InlineConfig['runner'] = vite.mergeConfig(
userConfig.runner ?? {},
const runner: InlineConfig['runner'] = defu(
inlineConfig.runner ?? {},
userConfig.runner ?? {},
);
const zip: InlineConfig['zip'] = vite.mergeConfig(
userConfig.zip ?? {},
const zip: InlineConfig['zip'] = defu(
inlineConfig.zip ?? {},
userConfig.zip ?? {},
);
return {
@@ -195,7 +198,7 @@ function mergeInlineConfig(
publicDir: inlineConfig.publicDir ?? userConfig.publicDir,
runner,
srcDir: inlineConfig.srcDir ?? userConfig.srcDir,
vite: viteConfig,
outDir: inlineConfig.outDir ?? userConfig.outDir,
zip,
analysis: {
enabled: inlineConfig.analysis?.enabled ?? userConfig.analysis?.enabled,
@@ -210,14 +213,17 @@ function mergeInlineConfig(
...userConfig.experimental,
...inlineConfig.experimental,
},
vite: undefined,
transformManifest: undefined,
};
}
function resolveInternalZipConfig(
root: string,
mergedConfig: InlineConfig,
): InternalConfig['zip'] {
): NullablyRequired<InternalConfig['zip']> {
return {
name: undefined,
sourcesTemplate: '{{name}}-{{version}}-sources.zip',
artifactTemplate: '{{name}}-{{version}}-{{browser}}.zip',
sourcesRoot: root,
@@ -236,46 +242,3 @@ function resolveInternalZipConfig(
],
};
}
async function resolveInternalViteConfig(
env: ConfigEnv,
mergedConfig: InlineConfig,
finalConfig: InternalConfig,
) {
const internalVite: vite.InlineConfig =
(await mergedConfig.vite?.(env)) ?? {};
internalVite.root = finalConfig.root;
internalVite.configFile = false;
internalVite.logLevel = 'warn';
internalVite.mode = env.mode;
internalVite.build ??= {};
internalVite.build.outDir = finalConfig.outDir;
internalVite.build.emptyOutDir = false;
internalVite.plugins ??= [];
internalVite.plugins.push(plugins.download(finalConfig));
internalVite.plugins.push(plugins.devHtmlPrerender(finalConfig));
internalVite.plugins.push(plugins.unimport(finalConfig));
internalVite.plugins.push(
plugins.virtualEntrypoint('background', finalConfig),
);
internalVite.plugins.push(
plugins.virtualEntrypoint('content-script', finalConfig),
);
internalVite.plugins.push(
plugins.virtualEntrypoint('unlisted-script', finalConfig),
);
internalVite.plugins.push(plugins.devServerGlobals(finalConfig));
internalVite.plugins.push(plugins.tsconfigPaths(finalConfig));
internalVite.plugins.push(plugins.noopBackground());
if (finalConfig.analysis.enabled) {
internalVite.plugins.push(plugins.bundleAnalysis());
}
internalVite.plugins.push(plugins.globals(finalConfig));
internalVite.plugins.push(plugins.excludeBrowserPolyfill(finalConfig));
return internalVite;
}
+39 -24
View File
@@ -2,11 +2,12 @@ import createJITI, { TransformOptions as JitiTransformOptions } from 'jiti';
import { InternalConfig } from '~/types';
import { createUnimport } from 'unimport';
import fs from 'fs-extra';
import { resolve } from 'path';
import { resolve } from 'node:path';
import { getUnimportOptions } from '~/core/utils/unimport';
import { removeProjectImportStatements } from '~/core/utils/strings';
import { normalizePath } from '~/core/utils/paths';
import { TransformOptions, transformSync } from 'esbuild';
import { fileURLToPath } from 'node:url';
/**
* Get the value from the default export of a `path`.
@@ -45,30 +46,44 @@ export async function importEntrypointFile<T>(
['Text:', text, 'No imports:', textNoImports, 'Code:', code].join('\n'),
);
const jiti = createJITI(__filename, {
cache: false,
debug: config.debug,
esmResolve: true,
alias: {
'webextension-polyfill': resolve(
config.root,
'node_modules/wxt/dist/virtual/mock-browser.js',
),
const jiti = createJITI(
typeof __filename !== 'undefined'
? __filename
: fileURLToPath(import.meta.url),
{
cache: false,
debug: config.debug,
esmResolve: true,
alias: {
'webextension-polyfill': resolve(
config.root,
'node_modules/wxt/dist/virtual/mock-browser.js',
),
},
// Continue using node to load TS files even if `bun run --bun` is detected. Jiti does not
// respect the custom transform function when using it's native bun option.
experimentalBun: false,
// List of extensions to transform with esbuild
extensions: [
'.ts',
'.cts',
'.mts',
'.tsx',
'.js',
'.cjs',
'.mjs',
'.jsx',
],
transform(opts) {
const isEntrypoint = opts.filename === normalPath;
return transformSync(
// Use modified source code for entrypoints
isEntrypoint ? code : opts.source,
getEsbuildOptions(opts),
);
},
},
// Continue using node to load TS files even if `bun run --bun` is detected. Jiti does not
// respect the custom transform function when using it's native bun option.
experimentalBun: false,
// List of extensions to transform with esbuild
extensions: ['.ts', '.cts', '.mts', '.tsx', '.js', '.cjs', '.mjs', '.jsx'],
transform(opts) {
const isEntrypoint = opts.filename === normalPath;
return transformSync(
// Use modified source code for entrypoints
isEntrypoint ? code : opts.source,
getEsbuildOptions(opts),
);
},
});
);
try {
const res = await jiti(path);
+1 -2
View File
@@ -1,7 +1,6 @@
import { findEntrypoints } from './find-entrypoints';
import { InternalConfig, BuildOutput } from '~/types';
import pc from 'picocolors';
import * as vite from 'vite';
import fs from 'fs-extra';
import { groupEntrypoints } from './group-entrypoints';
import { formatDuration } from '~/core/utils/time';
@@ -27,7 +26,7 @@ export async function internalBuild(
const target = `${config.browser}-mv${config.manifestVersion}`;
config.logger.info(
`${verb} ${pc.cyan(target)} for ${pc.cyan(config.mode)} with ${pc.green(
`Vite ${vite.version}`,
`${config.builder.name} ${config.builder.version}`,
)}`,
);
const startTime = Date.now();
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Manifest } from 'webextension-polyfill';
import type { Manifest } from '~/browser';
import { BuildOutput, EntrypointGroup, InternalConfig } from '~/types';
import { findEntrypoints } from './find-entrypoints';
import { generateTypesDir } from './generate-wxt-dir';
+5
View File
@@ -0,0 +1,5 @@
/**
* Module ID used to build the background in dev mode if the extension doesn't include a background
* script/service worker.
*/
export const VIRTUAL_NOOP_BACKGROUND_MODULE_ID = 'virtual:user-background';
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Manifest } from 'webextension-polyfill';
import type { Manifest } from '~/browser';
import { ContentScriptEntrypoint, InternalConfig } from '~/types';
import { resolvePerBrowserOption } from './entrypoints';
-21
View File
@@ -2,7 +2,6 @@ import { InternalConfig } from '~/types';
import fs from 'fs-extra';
import glob from 'fast-glob';
import { unnormalizePath } from './paths';
import path from 'node:path';
/**
* Only write the contents to a file if it results in a change. This prevents unnecessary file
@@ -36,23 +35,3 @@ export async function getPublicFiles(
const files = await glob('**/*', { cwd: config.publicDir });
return files.map(unnormalizePath);
}
/**
* Recursively remove all directories that are empty/
*/
export async function removeEmptyDirs(dir: string): Promise<void> {
const files = await fs.readdir(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
await removeEmptyDirs(filePath);
}
}
try {
await fs.rmdir(dir);
} catch {
// noop on failure - this means the directory was not empty.
}
}
+2 -5
View File
@@ -1,7 +1,7 @@
import { InternalConfig } from '~/types';
export function getGlobals(
config: InternalConfig,
config: Omit<InternalConfig, 'builder'>,
): Array<{ name: string; value: any; type: string }> {
return [
{
@@ -47,10 +47,7 @@ export function getGlobals(
];
}
export function getEntrypointGlobals(
config: InternalConfig,
entrypointName: string,
) {
export function getEntrypointGlobals(entrypointName: string) {
return [
{
name: surroundInUnderscore('ENTRYPOINT'),
+4 -7
View File
@@ -1,4 +1,4 @@
import type { Manifest } from 'webextension-polyfill';
import type { Manifest } from '~/browser';
import {
Entrypoint,
BackgroundEntrypoint,
@@ -23,7 +23,7 @@ import { getPackageJson } from './package';
import { normalizePath } from './paths';
import { writeFileIfDifferent } from './fs';
import { produce } from 'immer';
import * as vite from 'vite';
import defu from 'defu';
/**
* Writes the manifest to the output directory and the build output.
@@ -44,9 +44,6 @@ export async function writeManifest(
output.publicAssets.unshift({
type: 'asset',
fileName: 'manifest.json',
name: 'manifest',
needsCodeReference: false,
source: str,
});
}
@@ -78,9 +75,9 @@ export async function generateMainfest(
};
const userManifest = config.manifest;
const manifest = vite.mergeConfig(
baseManifest,
const manifest = defu(
userManifest,
baseManifest,
) as Manifest.WebExtensionManifest;
addEntrypoints(manifest, entrypoints, buildOutput, config);
+1 -1
View File
@@ -26,7 +26,7 @@ export async function isOnline(): Promise<boolean> {
*/
export async function fetchCached(
url: string,
config: InternalConfig,
config: Omit<InternalConfig, 'builder'>,
): Promise<string> {
let content: string = '';
+4 -4
View File
@@ -1,19 +1,19 @@
import nodePath from 'node:path';
import * as vite from 'vite';
import systemPath from 'node:path';
import normalize from 'normalize-path';
/**
* Converts system paths to normalized bundler path. On windows and unix, this returns paths with /
* instead of \.
*/
export function normalizePath(path: string): string {
return vite.normalizePath(path);
return normalize(path);
}
/**
* Given a normalized path, convert it to the system path style. On Windows, switch to \, otherwise use /.
*/
export function unnormalizePath(path: string): string {
return nodePath.normalize(path);
return systemPath.normalize(path);
}
export const CSS_EXTENSIONS = ['css', 'scss', 'sass', 'less', 'styl', 'stylus'];
+1 -2
View File
@@ -22,8 +22,7 @@ export function removeImportStatements(text: string): string {
export function removeProjectImportStatements(text: string): string {
const noImports = removeImportStatements(text);
return `import { defineContentScript, defineBackground } from 'wxt/client';
import { defineUnlistedScript } from 'wxt/sandbox';
return `import { defineUnlistedScript, defineContentScript, defineBackground } from 'wxt/sandbox';
${noImports}`;
}
+16 -44
View File
@@ -4,8 +4,7 @@
import { resolve } from 'path';
import { faker } from '@faker-js/faker';
import merge from 'lodash.merge';
import { Rollup } from 'vite';
import type { Manifest } from 'webextension-polyfill';
import type { Manifest } from '~/browser';
import {
FsCache,
InternalConfig,
@@ -15,6 +14,9 @@ import {
GenericEntrypoint,
OptionsEntrypoint,
PopupEntrypoint,
OutputChunk,
OutputFile,
OutputAsset,
} from '~/types';
import { mock } from 'vitest-mock-extended';
@@ -140,49 +142,19 @@ export const fakeGenericEntrypoint = fakeObjectCreator<GenericEntrypoint>(
}),
);
export const fakeRollupOutputChunk = fakeObjectCreator<Rollup.OutputChunk>(
() => ({
type: 'chunk',
code: '',
dynamicImports: [],
exports: [],
facadeModuleId: faker.helpers.arrayElement([null, fakeFile()]),
fileName: faker.string.alphanumeric(),
implicitlyLoadedBefore: [],
importedBindings: {},
imports: [],
isDynamicEntry: faker.datatype.boolean(),
isEntry: faker.datatype.boolean(),
isImplicitEntry: faker.datatype.boolean(),
map: null,
moduleIds: [],
modules: {},
name: faker.string.alpha(),
referencedFiles: [],
viteMetadata: {
importedAssets: new Set(),
importedCss: new Set(),
},
preliminaryFileName: faker.string.alphanumeric(),
sourcemapFileName: null,
}),
);
export const fakeOutputChunk = fakeObjectCreator<OutputChunk>(() => ({
type: 'chunk',
fileName: faker.string.alphanumeric(),
moduleIds: [],
}));
export const fakeRollupOutputAsset = fakeObjectCreator<Rollup.OutputAsset>(
() => ({
type: 'asset',
fileName: fakeFileName(),
name: faker.string.alpha(),
needsCodeReference: faker.datatype.boolean(),
source: '',
}),
);
export const fakeOutputAsset = fakeObjectCreator<OutputAsset>(() => ({
type: 'asset',
fileName: fakeFileName(),
}));
export function fakeRollupOutput(): Rollup.OutputAsset | Rollup.OutputChunk {
return faker.helpers.arrayElement([
fakeRollupOutputAsset(),
fakeRollupOutputChunk(),
]);
export function fakeOutputFile(): OutputFile {
return faker.helpers.arrayElement([fakeOutputAsset(), fakeOutputChunk()]);
}
export const fakeManifest = fakeObjectCreator<Manifest.WebExtensionManifest>(
@@ -228,7 +200,6 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
debug: faker.datatype.boolean(),
srcDir: fakeDir(),
typesDir: fakeDir(),
vite: () => ({}),
wxtDir: fakeDir(),
server: mock<WxtDevServer>(),
analysis: {
@@ -248,5 +219,6 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
experimental: {
includeBrowserPolyfill: true,
},
builder: mock(),
};
});
+8
View File
@@ -0,0 +1,8 @@
/**
* Remove optional from key, but keep undefined if present
*
* @example
* type Test = NullablyRequired<{a?: string, b: number}>
* // type Test = {a: string | undefined, b: number}
*/
export type NullablyRequired<T> = { [K in keyof Required<T>]: T[K] };
+3 -6
View File
@@ -1,9 +1,9 @@
import { UnimportOptions } from 'unimport';
import { InternalConfig } from '~/types';
import { mergeConfig } from 'vite';
import { defu } from 'defu';
export function getUnimportOptions(
config: InternalConfig,
config: Omit<InternalConfig, 'builder'>,
): Partial<UnimportOptions | false> {
if (config.imports === false) return false;
@@ -23,8 +23,5 @@ export function getUnimportOptions(
dirs: ['components', 'composables', 'hooks', 'utils'],
};
return mergeConfig(
defaultOptions,
config.imports,
) as Partial<UnimportOptions>;
return defu(config.imports, defaultOptions);
}
@@ -1,28 +0,0 @@
import path from 'node:path';
import * as vite from 'vite';
import { InternalConfig } from '~/types';
/**
* Creates an alias to redirect "webextension-polyfill" imports to WXT's `fakeBrowser`.
*
* This should only be used during tests.
*/
export function webextensionPolyfillAlias(
config: InternalConfig,
): vite.PluginOption {
return {
name: 'wxt:webextension-polyfill-test-alias',
config() {
return {
resolve: {
alias: {
'webextension-polyfill': path.resolve(
config.root,
'node_modules/wxt/dist/virtual/mock-browser',
),
},
},
};
},
};
}
@@ -1,24 +0,0 @@
import * as vite from 'vite';
/**
* Add all deps that import `webextension-polyfill` to `test.server.deps.inline`.
*
* TODO: Auto-detect non-wxt dependencies via `npm list`.
*/
export function webextensionPolyfillInlineDeps(): vite.PluginOption {
return {
name: 'wxt:testing-inline-deps',
config() {
const wxtModules = ['wxt/browser', 'wxt/client'];
return {
test: {
server: {
deps: {
inline: [...wxtModules],
},
},
},
};
},
};
}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { defineBackground } from '~/client/define-background';
import { defineBackground } from '~/sandbox/define-background';
import { BackgroundDefinition } from '~/types';
describe('defineBackground', () => {
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { defineContentScript } from '~/client/content-scripts/define-content-script';
import { defineContentScript } from '~/sandbox/define-content-script';
import { ContentScriptDefinition } from '~/types';
describe('defineContentScript', () => {
@@ -1,4 +1,4 @@
import { BackgroundDefinition } from '~/types';
import type { BackgroundDefinition } from '~/types';
export function defineBackground(main: () => void): BackgroundDefinition;
export function defineBackground(
@@ -1,4 +1,4 @@
import { ContentScriptDefinition } from '~/types';
import type { ContentScriptDefinition } from '~/types';
export function defineContentScript(
definition: ContentScriptDefinition,
+1 -1
View File
@@ -1,4 +1,4 @@
import { UnlistedScriptDefinition } from '~/types';
import type { UnlistedScriptDefinition } from '~/types';
export function defineUnlistedScript(
main: () => void,
+5
View File
@@ -1,4 +1,9 @@
/**
* Any runtime APIs that don't use the web extension APIs.
*
* @module wxt/sandbox
*/
export * from './define-unlisted-script';
export * from './define-background';
export * from './define-content-script';
export * from '@webext-core/match-patterns';
@@ -1,3 +1,5 @@
/// <reference types="vite/client" />
function print(method: (...args: any[]) => void, ...args: any[]) {
if (import.meta.env.MODE === 'production') return;
+1 -1
View File
@@ -8,7 +8,7 @@ import {
defineDriver,
Storage,
} from 'unstorage';
import browser, { Storage as BrowserStorage } from 'webextension-polyfill';
import { browser, Storage as BrowserStorage } from '~/browser';
export interface WebExtensionDriverOptions {
storageArea: 'sync' | 'local' | 'managed' | 'session';
+4 -6
View File
@@ -1,12 +1,11 @@
import * as vite from 'vite';
import type * as vite from 'vite';
import {
unimport,
download,
tsconfigPaths,
globals,
webextensionPolyfillAlias,
webextensionPolyfillInlineDeps,
} from '../core/vite-plugins';
webextensionPolyfillMock,
} from '../core/builders/vite/plugins';
import { getInternalConfig } from '~/core/utils/building';
import { InlineConfig } from '../types';
@@ -27,8 +26,7 @@ import { InlineConfig } from '../types';
*/
export function WxtVitest(inlineConfig?: InlineConfig): vite.PluginOption {
return getInternalConfig(inlineConfig ?? {}, 'serve').then((config) => [
webextensionPolyfillAlias(config),
webextensionPolyfillInlineDeps(),
webextensionPolyfillMock(config),
unimport(config),
globals(config),
download(config),
+169 -51
View File
@@ -1,9 +1,10 @@
import * as vite from 'vite';
import type { Manifest, Scripting } from 'webextension-polyfill';
import type * as vite from 'vite';
import type { Manifest, Scripting } from '~/browser';
import { UnimportOptions } from 'unimport';
import { LogLevel } from 'consola';
import { ContentScriptContext } from '../client/content-scripts/content-script-context';
import type { PluginVisualizerOptions } from 'rollup-plugin-visualizer';
import type { FSWatcher } from 'chokidar';
export interface InlineConfig {
/**
@@ -30,6 +31,12 @@ export interface InlineConfig {
* @default "${config.srcDir}/entrypoints"
*/
entrypointsDir?: string;
/**
* Output directory that stored build folders and ZIPs.
*
* @default ".output"
*/
outDir?: string;
/**
* > Only available when using the JS API. Not available in `wxt.config.ts` files
*
@@ -83,17 +90,6 @@ export interface InlineConfig {
* consola
*/
logger?: Logger;
/**
* Return custom Vite options from a function. See
* <https://vitejs.dev/config/shared-options.html>.
*
* [`root`](#root), [`configFile`](#configfile), and [`mode`](#mode) should be set in WXT's config
* instead of Vite's.
*
* This is a function because any vite plugins added need to be recreated for each individual
* build step, incase they have internal state causing them to fail when reused.
*/
vite?: (env: ConfigEnv) => WxtViteConfig | Promise<WxtViteConfig>;
/**
* Customize the `manifest.json` output. Can be an object, promise, or function that returns an
* object or promise.
@@ -194,10 +190,6 @@ export interface InlineConfig {
* Add additional paths to the `.wxt/tsconfig.json`. Use this instead of overwriting the `paths`
* in the root `tsconfig.json` if you want to add new paths.
*
* Passed into Vite's
* [`resolve.alias`](https://vitejs.dev/config/shared-options.html#resolve-alias) option and used
* to generate the `.wxt/tsconfig.json`.
*
* The key is the import alias and the value is either a relative path to the root directory or an absolute path.
*
* @example
@@ -233,43 +225,83 @@ export interface InlineConfig {
};
}
export interface WxtInlineViteConfig
extends Omit<vite.InlineConfig, 'root' | 'configFile' | 'mode' | 'build'> {
build?: Omit<vite.BuildOptions, 'outDir'>;
// TODO: Extract to @wxt/vite-builder and use module augmentation to include the vite field
export interface InlineConfig {
/**
* Return custom Vite options from a function. See
* <https://vitejs.dev/config/shared-options.html>.
*
* [`root`](#root), [`configFile`](#configfile), and [`mode`](#mode) should be set in WXT's config
* instead of Vite's.
*
* This is a function because any vite plugins added need to be recreated for each individual
* build step, incase they have internal state causing them to fail when reused.
*/
vite?: (env: ConfigEnv) => WxtViteConfig | Promise<WxtViteConfig>;
}
// TODO: Move into @wxt/vite-builder
export type WxtViteConfig = Omit<
vite.UserConfig,
'root' | 'configFile' | 'mode'
>;
export interface BuildOutput {
manifest: Manifest.WebExtensionManifest;
publicAssets: vite.Rollup.OutputAsset[];
publicAssets: OutputAsset[];
steps: BuildStepOutput[];
}
export type OutputFile = OutputChunk | OutputAsset;
export interface OutputChunk {
type: 'chunk';
/**
* Relative, normalized path relative to the output directory.
*
* Ex: "content-scripts/overlay.js"
*/
fileName: string;
/**
* Absolute, normalized paths to all dependencies this chunk relies on.
*/
moduleIds: string[];
}
export interface OutputAsset {
type: 'asset';
/**
* Relative, normalized path relative to the output directory.
*
* Ex: "icons/16.png"
*/
fileName: string;
}
export interface BuildStepOutput {
entrypoints: EntrypointGroup;
chunks: (vite.Rollup.OutputChunk | vite.Rollup.OutputAsset)[];
chunks: OutputFile[];
}
export interface WxtDevServer extends vite.ViteDevServer {
/**
* Ex: `3000`
*/
port: number;
/**
* Ex: `"localhost"`
*/
hostname: string;
/**
* Ex: `"http://localhost:3000"`
*/
origin: string;
export interface WxtDevServer
extends Omit<WxtBuilderServer, 'listen'>,
ServerInfo {
/**
* Stores the current build output of the server.
*/
currentOutput: BuildOutput;
/**
* Start the server on the first open port.
* Start the server.
*/
start(): Promise<void>;
/**
* Transform the HTML for dev mode.
*/
transformHtml(
url: string,
html: string,
originalUrl?: string | undefined,
): Promise<string>;
/**
* Tell the extension to reload by running `browser.runtime.reload`.
*/
@@ -327,7 +359,7 @@ export interface BaseEntrypoint {
* - `named.sandbox.html` &rarr; `named`
* - `named.sandbox/index.html` &rarr; `named`
* - `sandbox.html` &rarr; `sandbox`
* - `sandbox.index.html` &rarr; `sandbox`
* - `sandbox/index.html` &rarr; `sandbox`
* - `overlay.content.ts` &rarr; `overlay`
* - `overlay.content/index.ts` &rarr; `overlay`
*
@@ -407,7 +439,36 @@ export type EntrypointGroup = Entrypoint | Entrypoint[];
export type OnContentScriptStopped = (cb: () => void) => void;
export interface ContentScriptDefinition extends ExcludableEntrypoint {
export type ContentScriptDefinition =
| ContentScriptIsolatedWorldDefinition
| ContentScriptMainWorldDefinition;
export interface ContentScriptIsolatedWorldDefinition
extends ContentScriptBaseDefinition {
/**
* See https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts#isolated_world
* @default "ISOLATED"
*/
world?: 'ISOLATED';
/**
* Main function executed when the content script is loaded.
*/
main(ctx: ContentScriptContext): void | Promise<void>;
}
export interface ContentScriptMainWorldDefinition
extends ContentScriptBaseDefinition {
/**
* See https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts#isolated_world
*/
world: 'MAIN';
/**
* Main function executed when the content script is loaded.
*/
main(): void | Promise<void>;
}
export interface ContentScriptBaseDefinition extends ExcludableEntrypoint {
matches: PerBrowserOption<Manifest.ContentScript['matches']>;
/**
* See https://developer.chrome.com/docs/extensions/mv3/content_scripts/
@@ -446,11 +507,6 @@ export interface ContentScriptDefinition extends ExcludableEntrypoint {
* @default false
*/
matchOriginAsFallback?: PerBrowserOption<boolean>;
/**
* See https://developer.chrome.com/docs/extensions/mv3/content_scripts/
* @default "ISOLATED"
*/
world?: PerBrowserOption<'ISOLATED' | 'MAIN'>;
/**
* Customize how imported/generated styles are injected with the content script. Regardless of the
* mode selected, CSS will always be built and included in the output directory.
@@ -465,10 +521,6 @@ export interface ContentScriptDefinition extends ExcludableEntrypoint {
* @default "manifest"
*/
cssInjectionMode?: PerBrowserOption<'manifest' | 'manual' | 'ui'>;
/**
* Main function executed when the content script is loaded.
*/
main(ctx: ContentScriptContext): void | Promise<void>;
}
export interface BackgroundDefinition extends ExcludableEntrypoint {
@@ -590,7 +642,73 @@ export interface ExtensionRunnerConfig {
startUrls?: string[];
}
export type WxtViteConfig = Omit<
vite.UserConfig,
'root' | 'configFile' | 'mode'
>;
export interface WxtBuilder {
/**
* Name of tool used to build. Ex: "Vite" or "Webpack".
*/
name: string;
/**
* Version of tool used to build. Ex: "5.0.2"
*/
version: string;
/**
* Build a single entrypoint group. This is effectively one of the multiple "steps" during the
* build process.
*/
build(group: EntrypointGroup): Promise<BuildStepOutput>;
/**
* Start a dev server at the provided port.
*/
createServer(info: ServerInfo): Promise<WxtBuilderServer>;
}
export interface WxtBuilderServer {
/**
* Start the server.
*/
listen(): Promise<void>;
/**
* Transform the HTML for dev mode.
*/
transformHtml(
url: string,
html: string,
originalUrl?: string | undefined,
): Promise<string>;
/**
* The web socket server used to communicate with the extension.
*/
ws: {
/**
* Send a message via the server's websocket, with an optional payload.
*
* @example
* ws.send("wxt:reload-extension");
* ws.send("wxt:reload-content-script", { ... });
*/
send(message: string, payload?: any): void;
/**
* Listen for messages over the server's websocket.
*/
on(message: string, cb: (payload: any) => void): void;
};
/**
* Chokidar file watcher instance.
*/
watcher: FSWatcher;
}
export interface ServerInfo {
/**
* Ex: `3000`
*/
port: number;
/**
* Ex: `"localhost"`
*/
hostname: string;
/**
* Ex: `"http://localhost:3000"`
*/
origin: string;
}
+7
View File
@@ -0,0 +1,7 @@
// Globals defined by the vite-plugins/devServerGlobals.ts and utils/globals.ts
declare const __COMMAND__: 'build' | 'serve';
declare const __DEV_SERVER_PROTOCOL__: string;
declare const __DEV_SERVER_HOSTNAME__: string;
declare const __DEV_SERVER_PORT__: string;
declare const __MANIFEST_VERSION__: 2 | 3;
declare const __ENTRYPOINT__: string;
+9 -3
View File
@@ -1,4 +1,3 @@
import * as vite from 'vite';
import {
WxtDevServer,
Logger,
@@ -8,10 +7,11 @@ import {
ExtensionRunnerConfig,
ConfigEnv,
UserConfig,
WxtBuilder,
} from './external';
import { UnimportOptions } from 'unimport';
import { ResolvedConfig } from 'c12';
import type { Manifest } from 'webextension-polyfill';
import type { Manifest } from '~/browser';
import type { PluginVisualizerOptions } from 'rollup-plugin-visualizer';
export interface InternalConfig {
@@ -31,7 +31,6 @@ export interface InternalConfig {
env: ConfigEnv;
logger: Logger;
imports: false | Partial<UnimportOptions>;
vite: (env: ConfigEnv) => Promise<vite.InlineConfig> | vite.InlineConfig;
manifest: UserManifest;
fsCache: FsCache;
server?: WxtDevServer;
@@ -56,6 +55,7 @@ export interface InternalConfig {
experimental: {
includeBrowserPolyfill: boolean;
};
builder: WxtBuilder;
}
export interface FsCache {
@@ -67,3 +67,9 @@ export interface ExtensionRunner {
openBrowser(config: InternalConfig): Promise<void>;
closeBrowser(): Promise<void>;
}
export type VirtualEntrypointType =
| 'content-script-main-world'
| 'content-script-isolated-world'
| 'background'
| 'unlisted-script';
+11
View File
@@ -0,0 +1,11 @@
# WXT Virtual Entrypoints
This folder contains scripts that are either loaded as entrypoints to JS files or included in HTML files, just like a project using WXT might load their own scripts.
While they are bundled and shipped inside WXT, Vite considers them apart of your project's source code, not WXT's. This means they cannot import 3rd party modules directly, otherwise `pnpm i --shamefully-hoist=false` and Yarn PnP will fail.
For this reason, the virtual entrypoints get their own TS project to isolate them from the rest of the project. They can only import from `wxt/*` or utils that don't have any imports from node_modules, like the logger.
When bundling WXT for publishing to NPM, all the `wxt/*` imports are marked as external and resolved when building your application. Other imports are added inline.
See https://github.com/wxt-dev/wxt/issues/286#issuecomment-1858888390 for more details.

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