Compare commits

...

26 Commits

Author SHA1 Message Date
GitHub Actions c298d8552b chore(release): v0.14.6 2024-01-24 06:34:25 +00:00
Aaron 87267347f3 feat: Add basic validation for entrypoint options (#368) 2024-01-24 00:31:05 -06:00
Aaron 326e84b667 feat: Restart dev mode when saving config (#365) 2024-01-19 22:58:49 -06:00
Aaron Klinker 4b005b450e ci: Check for type errors in demo before building 2024-01-19 22:57:06 -06:00
Aaron 3bf40fc4ed fix: Sort build output files naturally (#364) 2024-01-19 11:35:09 -06:00
Aaron 9ace395e11 fix: Add subdependency bin directory so wxt build --analyze works with PNPM (#363) 2024-01-19 11:32:18 -06:00
GitHub Actions 64f42064ae chore(release): v0.14.5 2024-01-19 14:36:07 +00:00
Aaron a525d786f1 feat: Add dev.reloadCommand config (#362) 2024-01-19 08:33:03 -06:00
Aaron 948ee810da fix: Disable reload dev command when 4 commands are already registered (#361) 2024-01-19 07:47:04 -06:00
GitHub Actions 8687b2980e chore(release): v0.14.4 2024-01-18 20:54:29 +00:00
Aaron 6f0814bc97 fix: Allow requiring built-in node modules from ESM CLI (#356) 2024-01-18 14:51:35 -06:00
Aaron 148c0da51f chore: Add unit tests for passing flags via the CLI (#354) 2024-01-18 14:49:35 -06:00
GitHub Actions ba09f3e0f1 chore(release): v0.14.3 2024-01-17 05:20:19 +00:00
Yuns 54e24652da fix: Make getArrayFromFlags result can be undefined (#352) 2024-01-16 23:15:18 -06:00
GitHub Actions d6516142d2 chore(release): v0.14.2 2024-01-16 19:57:03 +00:00
Yuns 460667e3c0 feat: Add filterEntrypoints option to speed up development (#344)
Co-authored-by: Aaron Klinker <aaronklinker1@gmail.com>
2024-01-16 13:52:58 -06:00
Armin 6ed61d0766 docs: Fix typos and outdated ui function usage (#347) 2024-01-16 13:01:12 -06:00
Aaron 971daf2784 fix: Allow browser.runtime.getURL to include hashes and query params for HTML paths (#350) 2024-01-16 12:59:38 -06:00
Aaron 89d8ef9a50 fix: Improve error message and document use of imported variables outside an entrypoint's main function (#346) 2024-01-14 09:36:22 -06:00
Aaron Klinker a329e24818 chore: Fix typo in function name 2024-01-12 13:03:43 -06:00
Aaron 249cf63da8 perf: Only call findEntrypoint once per build (#342) 2024-01-12 13:02:44 -06:00
Aaron Klinker 70a496108a chore: Update templates to ^0.14.0 2024-01-12 08:53:32 -06:00
GitHub Actions 214889e052 chore(release): v0.14.1 2024-01-12 14:50:29 +00:00
Aaron Klinker b6ab7a9567 fix: Use Alt+R/Opt+R to reload extension during development
This closes #339
2024-01-12 08:47:21 -06:00
Aaron Klinker 1ceb9e2e80 Add missing breaking change to changelog 2024-01-11 17:00:43 -06:00
Aaron Klinker 15ea81514f Update changelog 2024-01-11 13:28:41 -06:00
42 changed files with 1526 additions and 158 deletions
+2 -2
View File
@@ -30,10 +30,10 @@ jobs:
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
- run: pnpm build:all
working-directory: demo
- run: pnpm tsc --noEmit
working-directory: demo
- run: pnpm build:all
working-directory: demo
- run: pnpm wxt zip
working-directory: demo
- run: pnpm vitest run
+100 -1
View File
@@ -1,5 +1,98 @@
# Changelog
## v0.14.6
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.5...v0.14.6)
### 🚀 Enhancements
- Restart dev mode when saving config ([#365](https://github.com/wxt-dev/wxt/pull/365))
- Add basic validation for entrypoint options ([#368](https://github.com/wxt-dev/wxt/pull/368))
### 🩹 Fixes
- Add subdependency bin directory so `wxt build --analyze` works with PNPM ([#363](https://github.com/wxt-dev/wxt/pull/363))
- Sort build output files naturally ([#364](https://github.com/wxt-dev/wxt/pull/364))
### 🤖 CI
- Check for type errors in demo before building ([4b005b4](https://github.com/wxt-dev/wxt/commit/4b005b4))
## v0.14.5
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.4...v0.14.5)
### 🚀 Enhancements
- Add `dev.reloadCommand` config ([#362](https://github.com/wxt-dev/wxt/pull/362))
### 🩹 Fixes
- Disable reload dev command when 4 commands are already registered ([#361](https://github.com/wxt-dev/wxt/pull/361))
## v0.14.4
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.3...v0.14.4)
### 🩹 Fixes
- Allow requiring built-in node modules from ESM CLI ([#356](https://github.com/wxt-dev/wxt/pull/356))
### 🏡 Chore
- Add unit tests for passing flags via the CLI ([#354](https://github.com/wxt-dev/wxt/pull/354))
## v0.14.3
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.2...v0.14.3)
### 🩹 Fixes
- Make `getArrayFromFlags` result can be undefined ([#352](https://github.com/wxt-dev/wxt/pull/352))
### ❤️ Contributors
- Yuns ([@yunsii](http://github.com/yunsii))
## v0.14.2
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.1...v0.14.2)
### 🚀 Enhancements
- Add `filterEntrypoints` option to speed up development ([#344](https://github.com/wxt-dev/wxt/pull/344))
### 🔥 Performance
- Only call `findEntrypoint` once per build ([#342](https://github.com/wxt-dev/wxt/pull/342))
### 🩹 Fixes
- Improve error message and document use of imported variables outside an entrypoint's `main` function ([#346](https://github.com/wxt-dev/wxt/pull/346))
- Allow `browser.runtime.getURL` to include hashes and query params for HTML paths ([#350](https://github.com/wxt-dev/wxt/pull/350))
### 📖 Documentation
- Fix typos and outdated ui function usage ([#347](https://github.com/wxt-dev/wxt/pull/347))
### 🏡 Chore
- Update templates to `^0.14.0` ([70a4961](https://github.com/wxt-dev/wxt/commit/70a4961))
- Fix typo in function name ([a329e24](https://github.com/wxt-dev/wxt/commit/a329e24))
### ❤️ Contributors
- Yuns ([@yunsii](http://github.com/yunsii))
- Armin
## v0.14.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.0...v0.14.1)
### 🩹 Fixes
- Use `Alt+R`/`Opt+R` to reload extension during development ([b6ab7a9](https://github.com/wxt-dev/wxt/commit/b6ab7a9))
## v0.14.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.5...v0.14.0)
@@ -10,7 +103,13 @@
#### ⚠️ Breaking Changes
- ⚠️ Refactor content script UI functions and add helper for "integrated" UIs ([#333](https://github.com/wxt-dev/wxt/pull/333))
`createContentScriptUi` and `createContentScriptIframe`, and some of their options, have been renamed:
- `createContentScriptUi({ ... })` &rarr; `createShadowRootUi({ ... })`
- `createContentScriptIframe({ ... })` &rarr; `createIframeUi({ ... })`
- `type: "inline" | "overlay" | "modal"` has been changed to `position: "inline" | "overlay" | "modal"`
- `onRemove` is now called ***before*** the UI is removed from the DOM, previously it was called after the UI was removed
- `mount` option has been renamed to `onMount`, to better match the related option, `onRemove`.
## v0.13.5
+4
View File
@@ -15,6 +15,10 @@ export default defineBackground(() => {
browser.runtime.getURL('/');
browser.runtime.getURL('/background.js');
browser.runtime.getURL('/icon/128.png');
browser.runtime.getURL('/example.html#hash');
browser.runtime.getURL('/example.html?query=param');
// @ts-expect-error: should only allow hashes/query params on HTML files
browser.runtime.getURL('/icon-128.png?query=param');
// @ts-expect-error: should only accept known message names
browser.i18n.getMessage('test');
+8 -5
View File
@@ -95,7 +95,7 @@ export default defineContentScript({
});
```
```ts [Svelete]
```ts [Svelte]
// entrypoints/example-ui.content/index.ts
import App from './App.svelte';
@@ -243,7 +243,8 @@ export default defineContentScript({
main(ctx) {
// 3. Define your UI
const ui = createIntegratedUi(ctx, {
const ui = createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
@@ -264,7 +265,7 @@ export default defineContentScript({
});
```
```ts [Svelete]
```ts [Svelte]
// 1. Import the style
import './style.css';
import App from './App.svelte';
@@ -275,7 +276,8 @@ export default defineContentScript({
main(ctx) {
// 3. Define your UI
const ui = createIntegratedUi(ctx, {
const ui = createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
@@ -308,7 +310,8 @@ export default defineContentScript({
main(ctx) {
// 3. Define your UI
const ui = createIntegratedUi(ctx, {
const ui = createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
+53
View File
@@ -86,3 +86,56 @@ export default defineContentScript({
:::info
For a full list of entrypoints and each of their options, see the [`/entrypoints` folder](/entrypoints/background) documentation.
:::
### Side Effects
You cannot use imported variables outside the `main` function is JS entrypoints. This includes options, as shown below:
```ts
// entrypoints/content.ts
import { GOOGLE_MATCHES } from '~/utils/match-patterns';
export default defineContentScript({
matches: GOOGLE_MATCHES,
main() {
// ...
},
});
```
```
$ wxt build
wxt build
WXT 0.14.1
Building chrome-mv3 for production with Vite 5.0.5
✖ Command failed after 360 ms
[8:55:54 AM] ERROR entrypoints/content.ts: Cannot use imported variable "GOOGLE_MATCHES" before main function. See https://wxt.dev/guide/entrypoints.html#side-effects
```
This throws an error because WXT needs to import each entrypoint during the build process to extract its definition (containing the `match`, `run_at`, `include`/`exclude`, etc.) to render the `manifest.json` correctly. Before loading an entrypoint, a transformation is applied to remove all imports. This prevents imported modules (local or NPM) with side-effects from running during the build process, potentially throwing an error.
:::details Why?
When importing your entrypoint to get its definition, the file is imported in a **_node environement_**, and doesn't have access to the `window`, `chrome`, or `browser` globals a web extension ususally has access to. If WXT doesn't remove all the imports from the file, the imported modules could try and access one of these variables, throwing an error.
:::
:::warning
See [`wxt-dev/wxt#336`](https://github.com/wxt-dev/wxt/issues/336) to track the status of this bug.
:::
Usually, this error occurs when you try to extract options into a shared file or try to run code outside the `main` function. To fix the example from above, use litteral values when defining an entrypoint instead of importing them:
```ts
import { GOOGLE_MATCHES } from '~/utils/match-patterns'; // [!code --]
export default defineContentScript({
matches: GOOGLE_MATCHES, // [!code --]
matches: ['*//*.google.com/*'], // [!code ++]
main() {
// ...
},
});
```
+2
View File
@@ -49,8 +49,10 @@ describe('TypeScript Project', () => {
| "/options.html"
| "/popup.html"
| "/sandbox.html"
type HtmlPublicPath = Extract<PublicPath, \`\${string}.html\`>
export interface WxtRuntime extends Runtime.Static {
getURL(path: PublicPath): string;
getURL(path: \`\${HtmlPublicPath}\${string}\`): string;
}
}
"
+4 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wxt",
"type": "module",
"version": "0.14.0",
"version": "0.14.6",
"description": "Next gen framework for developing web extensions",
"engines": {
"node": ">=18",
@@ -125,7 +125,9 @@
"jiti": "^1.21.0",
"json5": "^2.2.3",
"linkedom": "^0.16.1",
"manage-path": "^2.0.0",
"minimatch": "^9.0.3",
"natural-compare": "^1.4.0",
"normalize-path": "^3.0.0",
"ora": "^7.0.1",
"picocolors": "^1.0.0",
@@ -141,6 +143,7 @@
"@faker-js/faker": "^8.3.1",
"@types/fs-extra": "^11.0.4",
"@types/lodash.merge": "^4.6.9",
"@types/natural-compare": "^1.4.3",
"@types/node": "^20.10.3",
"@types/normalize-path": "^3.0.2",
"@types/prompts": "^2.4.9",
+21
View File
@@ -74,9 +74,15 @@ importers:
linkedom:
specifier: ^0.16.1
version: 0.16.1
manage-path:
specifier: ^2.0.0
version: 2.0.0
minimatch:
specifier: ^9.0.3
version: 9.0.3
natural-compare:
specifier: ^1.4.0
version: 1.4.0
normalize-path:
specifier: ^3.0.0
version: 3.0.0
@@ -117,6 +123,9 @@ importers:
'@types/lodash.merge':
specifier: ^4.6.9
version: 4.6.9
'@types/natural-compare':
specifier: ^1.4.3
version: 1.4.3
'@types/node':
specifier: ^20.10.3
version: 20.10.3
@@ -1106,6 +1115,10 @@ packages:
resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
dev: false
/@types/natural-compare@1.4.3:
resolution: {integrity: sha512-XCAxy+Gg6+S6VagwzcknnvCKujj/bVv1q+GFuCrFEelqaZPqJoC+FeXLwc2dp+oLP7qDZQ4ZfQiTJQ9sIUmlLw==}
dev: true
/@types/node@20.10.3:
resolution: {integrity: sha512-XJavIpZqiXID5Yxnxv3RUDKTN5b81ddNC3ecsA0SoFXz/QU8OGBwZGMomiq0zw+uuqbL/krztv/DINAQ/EV4gg==}
dependencies:
@@ -3352,6 +3365,10 @@ packages:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
dev: false
/manage-path@2.0.0:
resolution: {integrity: sha512-NJhyB+PJYTpxhxZJ3lecIGgh4kwIY2RAh44XvAz9UlqthlQwtPBf62uBVR8XaD8CRuSjQ6TnZH2lNJkbLPZM2A==}
dev: false
/mark.js@8.11.1:
resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==}
dev: true
@@ -3533,6 +3550,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
/natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: false
/ncp@2.0.0:
resolution: {integrity: sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==}
hasBin: true
+5
View File
@@ -97,6 +97,11 @@ const config: tsup.Options[] = [
cli: 'src/cli.ts',
},
format: ['esm'],
banner: {
// Fixes dynamic require of nodejs modules. See https://github.com/wxt-dev/wxt/issues/355
// https://github.com/evanw/esbuild/issues/1921#issuecomment-1152991694
js: "import { createRequire } from 'module';const require = createRequire(import.meta.url);",
},
},
];
+376
View File
@@ -0,0 +1,376 @@
import { describe, it, vi, beforeEach, expect } from 'vitest';
import { build } from '../core/build';
import { createServer } from '../core/create-server';
import { zip } from '../core/zip';
import { prepare } from '../core/prepare';
import { clean } from '../core/clean';
import { initialize } from '../core/initialize';
import { mock } from 'vitest-mock-extended';
import consola from 'consola';
vi.mock('../core/build');
const buildMock = vi.mocked(build);
vi.mock('../core/create-server');
const createServerMock = vi.mocked(createServer);
vi.mock('../core/zip');
const zipMock = vi.mocked(zip);
vi.mock('../core/prepare');
const prepareMock = vi.mocked(prepare);
vi.mock('../core/clean');
const cleanMock = vi.mocked(clean);
vi.mock('../core/initialize');
const initializeMock = vi.mocked(initialize);
consola.wrapConsole();
const ogArgv = process.argv;
function mockArgv(...args: string[]) {
process.argv = ['/bin/node', 'bin/wxt.mjs', ...args];
}
describe('CLI', () => {
beforeEach(() => {
vi.resetModules();
process.argv = ogArgv;
createServerMock.mockResolvedValue(mock());
});
describe('dev', () => {
it('should not pass any config when no flags are passed', async () => {
mockArgv();
await import('../cli');
expect(createServerMock).toBeCalledWith({});
});
it('should respect passing a custom root', async () => {
mockArgv('path/to/root');
await import('../cli');
expect(createServerMock).toBeCalledWith({
root: 'path/to/root',
});
});
it('should respect a custom config file', async () => {
mockArgv('-c', './path/to/config.ts');
await import('../cli');
expect(createServerMock).toBeCalledWith({
configFile: './path/to/config.ts',
});
});
it('should respect passing a custom mode', async () => {
mockArgv('-m', 'development');
await import('../cli');
expect(createServerMock).toBeCalledWith({
mode: 'development',
});
});
it('should respect passing a custom browser', async () => {
mockArgv('-b', 'firefox');
await import('../cli');
expect(createServerMock).toBeCalledWith({
browser: 'firefox',
});
});
it('should pass correct filtered entrypoints', async () => {
mockArgv('-e', 'popup', '-e', 'options');
await import('../cli');
expect(createServerMock).toBeCalledWith({
filterEntrypoints: ['popup', 'options'],
});
});
it('should respect passing --mv2', async () => {
mockArgv('--mv2');
await import('../cli');
expect(createServerMock).toBeCalledWith({
manifestVersion: 2,
});
});
it('should respect passing --mv3', async () => {
mockArgv('--mv3');
await import('../cli');
expect(createServerMock).toBeCalledWith({
manifestVersion: 3,
});
});
it('should respect passing --debug', async () => {
mockArgv('--debug');
await import('../cli');
expect(createServerMock).toBeCalledWith({
debug: true,
});
});
});
describe('build', () => {
it('should not pass any config when no flags are passed', async () => {
mockArgv('build');
await import('../cli');
expect(buildMock).toBeCalledWith({});
});
it('should respect passing a custom root', async () => {
mockArgv('build', 'path/to/root');
await import('../cli');
expect(buildMock).toBeCalledWith({
root: 'path/to/root',
});
});
it('should respect a custom config file', async () => {
mockArgv('build', '-c', './path/to/config.ts');
await import('../cli');
expect(buildMock).toBeCalledWith({
configFile: './path/to/config.ts',
});
});
it('should respect passing a custom mode', async () => {
mockArgv('build', '-m', 'development');
await import('../cli');
expect(buildMock).toBeCalledWith({
mode: 'development',
});
});
it('should respect passing a custom browser', async () => {
mockArgv('build', '-b', 'firefox');
await import('../cli');
expect(buildMock).toBeCalledWith({
browser: 'firefox',
});
});
it('should pass correct filtered entrypoints', async () => {
mockArgv('build', '-e', 'popup', '-e', 'options');
await import('../cli');
expect(buildMock).toBeCalledWith({
filterEntrypoints: ['popup', 'options'],
});
});
it('should respect passing --mv2', async () => {
mockArgv('build', '--mv2');
await import('../cli');
expect(buildMock).toBeCalledWith({
manifestVersion: 2,
});
});
it('should respect passing --mv3', async () => {
mockArgv('build', '--mv3');
await import('../cli');
expect(buildMock).toBeCalledWith({
manifestVersion: 3,
});
});
it('should include analysis in the build', async () => {
mockArgv('build', '--analyze');
await import('../cli');
expect(buildMock).toBeCalledWith({
analysis: {
enabled: true,
},
});
});
it('should respect passing --debug', async () => {
mockArgv('build', '--debug');
await import('../cli');
expect(buildMock).toBeCalledWith({
debug: true,
});
});
});
describe('zip', () => {
it('should not pass any config when no flags are passed', async () => {
mockArgv('zip');
await import('../cli');
expect(zipMock).toBeCalledWith({});
});
it('should respect passing a custom root', async () => {
mockArgv('zip', 'path/to/root');
await import('../cli');
expect(zipMock).toBeCalledWith({
root: 'path/to/root',
});
});
it('should respect a custom config file', async () => {
mockArgv('zip', '-c', './path/to/config.ts');
await import('../cli');
expect(zipMock).toBeCalledWith({
configFile: './path/to/config.ts',
});
});
it('should respect passing a custom mode', async () => {
mockArgv('zip', '-m', 'development');
await import('../cli');
expect(zipMock).toBeCalledWith({
mode: 'development',
});
});
it('should respect passing a custom browser', async () => {
mockArgv('zip', '-b', 'firefox');
await import('../cli');
expect(zipMock).toBeCalledWith({
browser: 'firefox',
});
});
it('should respect passing --mv2', async () => {
mockArgv('zip', '--mv2');
await import('../cli');
expect(zipMock).toBeCalledWith({
manifestVersion: 2,
});
});
it('should respect passing --mv3', async () => {
mockArgv('zip', '--mv3');
await import('../cli');
expect(zipMock).toBeCalledWith({
manifestVersion: 3,
});
});
it('should respect passing --debug', async () => {
mockArgv('zip', '--debug');
await import('../cli');
expect(zipMock).toBeCalledWith({
debug: true,
});
});
});
describe('prepare', () => {
it('should not pass any config when no flags are passed', async () => {
mockArgv('prepare');
await import('../cli');
expect(prepareMock).toBeCalledWith({});
});
it('should respect passing a custom root', async () => {
mockArgv('prepare', 'path/to/root');
await import('../cli');
expect(prepareMock).toBeCalledWith({
root: 'path/to/root',
});
});
it('should respect a custom config file', async () => {
mockArgv('prepare', '-c', './path/to/config.ts');
await import('../cli');
expect(prepareMock).toBeCalledWith({
configFile: './path/to/config.ts',
});
});
it('should respect passing --debug', async () => {
mockArgv('prepare', '--debug');
await import('../cli');
expect(prepareMock).toBeCalledWith({
debug: true,
});
});
});
describe('clean', () => {
it('should not pass any config when no flags are passed', async () => {
mockArgv('clean');
await import('../cli');
expect(cleanMock).toBeCalledWith(undefined);
});
it('should respect passing a custom root', async () => {
mockArgv('clean', 'path/to/root');
await import('../cli');
expect(cleanMock).toBeCalledWith('path/to/root');
});
});
describe('init', () => {
it('should not pass any options when no flags are passed', async () => {
mockArgv('init');
await import('../cli');
expect(initializeMock).toBeCalledWith({});
});
it('should respect the provided folder', async () => {
mockArgv('init', 'path/to/folder');
await import('../cli');
expect(initializeMock).toBeCalledWith({
directory: 'path/to/folder',
});
});
it('should respect passing --template', async () => {
mockArgv('init', '-t', 'vue');
await import('../cli');
expect(initializeMock).toBeCalledWith({
template: 'vue',
});
});
it('should respect passing --pm', async () => {
mockArgv('init', '--pm', 'pnpm');
await import('../cli');
expect(initializeMock).toBeCalledWith({
packageManager: 'pnpm',
});
});
});
});
+34 -5
View File
@@ -4,6 +4,7 @@ import { build, clean, createServer, initialize, prepare, zip } from '~/core';
import consola, { LogLevels } from 'consola';
import { printHeader } from '~/core/utils/log';
import { formatDuration } from '~/core/utils/time';
import { ValidationError } from './core/utils/validation';
// TODO: Remove. See https://github.com/wxt-dev/wxt/issues/277
process.env.VITE_CJS_IGNORE_WARNING = 'true';
@@ -20,6 +21,13 @@ cli
.option('-c, --config <file>', 'use specified config file')
.option('-m, --mode <mode>', 'set env mode')
.option('-b, --browser <browser>', 'specify a browser')
.option(
'-e, --filter-entrypoint <entrypoint>',
'only build specific entrypoints',
{
type: [],
},
)
.option('--mv3', 'target manifest v3')
.option('--mv2', 'target manifest v2')
.action(
@@ -31,6 +39,7 @@ cli
manifestVersion: flags.mv3 ? 3 : flags.mv2 ? 2 : undefined,
configFile: flags.config,
debug: flags.debug,
filterEntrypoints: getArrayFromFlags(flags, 'filterEntrypoint'),
});
await server.start();
return { isOngoing: true };
@@ -43,6 +52,13 @@ cli
.option('-c, --config <file>', 'use specified config file')
.option('-m, --mode <mode>', 'set env mode')
.option('-b, --browser <browser>', 'specify a browser')
.option(
'-e, --filter-entrypoint <entrypoint>',
'only build specific entrypoints',
{
type: [],
},
)
.option('--mv3', 'target manifest v3')
.option('--mv2', 'target manifest v2')
.option('--analyze', 'visualize extension bundle')
@@ -55,9 +71,8 @@ cli
manifestVersion: flags.mv3 ? 3 : flags.mv2 ? 2 : undefined,
configFile: flags.config,
debug: flags.debug,
analysis: {
enabled: flags.analyze,
},
analysis: flags.analyze ? { enabled: true } : undefined,
filterEntrypoints: getArrayFromFlags(flags, 'filterEntrypoint'),
});
}),
);
@@ -125,7 +140,7 @@ cli
),
);
cli.parse();
cli.parse(process.argv);
/**
* Wrap an action handler to add a timer, error handling, and maybe enable debug mode.
@@ -160,8 +175,22 @@ function wrapAction(
consola.fail(
`Command failed after ${formatDuration(Date.now() - startTime)}`,
);
consola.error(err);
if (err instanceof ValidationError) {
// Don't log these errors, they've already been logged
} else {
consola.error(err);
}
process.exit(1);
}
};
}
/**
* Array flags, when not passed, are either `undefined` or `[undefined]`. This function filters out
* the
*/
function getArrayFromFlags<T>(flags: any, name: string): T[] | undefined {
const array = [flags[name]].flat() as Array<T | undefined>;
const result = array.filter((item) => item != null) as T[];
return result.length ? result : undefined;
}
+4 -1
View File
@@ -12,7 +12,7 @@ import {
import * as wxtPlugins from './plugins';
import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
export async function craeteViteBuilder(
export async function createViteBuilder(
inlineConfig: InlineConfig,
userConfig: UserConfig,
wxtConfig: Omit<InternalConfig, 'builder'>,
@@ -230,6 +230,9 @@ export async function craeteViteBuilder(
async listen() {
await viteServer.listen(info.port);
},
async close() {
await viteServer.close();
},
transformHtml(...args) {
return viteServer.transformIndexHtml(...args);
},
+68 -16
View File
@@ -20,6 +20,7 @@ import {
getInternalConfig,
detectDevChanges,
rebuild,
findEntrypoints,
} from '~/core/utils/building';
import { createExtensionRunner } from '~/core/runners';
import { consola } from 'consola';
@@ -48,22 +49,46 @@ export async function createServer(
origin,
};
const buildAndOpenBrowser = async () => {
// Build after starting the dev server so it can be used to transform HTML files
server.currentOutput = await internalBuild(config);
// Open browser after everything is ready to go.
await runner.openBrowser(config);
};
/**
* Stops the previous runner, grabs the latest config, and recreates the runner.
*/
const closeAndRecreateRunner = async () => {
await runner.closeBrowser();
config = await getLatestConfig();
runner = await createExtensionRunner(config);
};
// Server instance must be created first so its reference can be added to the internal config used
// to pre-render entrypoints
const server: WxtDevServer = {
...serverInfo,
watcher: undefined as any, // Filled out later down below
ws: undefined as any, // Filled out later down below
currentOutput: undefined as any, // Filled out later down below
get watcher() {
return builderServer.watcher;
},
get ws() {
return builderServer.ws;
},
currentOutput: undefined,
async start() {
await builderServer.listen();
config.logger.success(`Started dev server @ ${serverInfo.origin}`);
// Build after starting the dev server so it can be used to transform HTML files
server.currentOutput = await internalBuild(config);
// Open browser after everything is ready to go.
await runner.openBrowser(config);
await buildAndOpenBrowser();
},
async stop() {
await runner.closeBrowser();
await builderServer.close();
},
async restart() {
await closeAndRecreateRunner();
await buildAndOpenBrowser();
},
transformHtml(url, html, originalUrl) {
return builderServer.transformHtml(url, html, originalUrl);
@@ -77,23 +102,25 @@ export async function createServer(
reloadExtension() {
server.ws.send('wxt:reload-extension');
},
async restartBrowser() {
await closeAndRecreateRunner();
await runner.openBrowser(config);
},
};
const getLatestConfig = () =>
getInternalConfig(inlineConfig ?? {}, 'serve', server);
let config = await getLatestConfig();
const [runner, builderServer] = await Promise.all([
let [runner, builderServer] = await Promise.all([
createExtensionRunner(config),
config.builder.createServer(server),
]);
server.watcher = builderServer.watcher;
server.ws = builderServer.ws;
// Register content scripts for the first time after the background starts up since they're not
// listed in the manifest
server.ws.on('wxt:background-initialized', () => {
if (server.currentOutput == null) return;
reloadContentScripts(server.currentOutput.steps, config, server);
});
@@ -137,18 +164,39 @@ function createFileReloader(options: {
changeQueue.push([event, path]);
await fileChangedMutex.runExclusive(async () => {
const fileChanges = changeQueue.splice(0, changeQueue.length);
if (server.currentOutput == null) return;
const fileChanges = changeQueue
.splice(0, changeQueue.length)
.map(([_, file]) => file);
if (fileChanges.length === 0) return;
const changes = detectDevChanges(fileChanges, server.currentOutput);
const changes = detectDevChanges(
config,
fileChanges,
server.currentOutput,
);
if (changes.type === 'no-change') return;
if (changes.type === 'full-restart') {
config.logger.info('Config changed, restarting server...');
server.restart();
return;
}
if (changes.type === 'browser-restart') {
config.logger.info('Runner config changed, restarting browser...');
server.restartBrowser();
return;
}
// Log the entrypoints that were effected
config.logger.info(
`Changed: ${Array.from(new Set(fileChanges.map((change) => change[1])))
`Changed: ${Array.from(new Set(fileChanges))
.map((file) => pc.dim(relative(config.root, file)))
.join(', ')}`,
);
const rebuiltNames = changes.rebuildGroups
.flat()
.map((entry) => {
@@ -159,8 +207,10 @@ function createFileReloader(options: {
.join(pc.dim(', '));
// Rebuild entrypoints on change
const allEntrypoints = await findEntrypoints(config);
const { output: newOutput } = await rebuild(
config,
allEntrypoints,
// TODO: this excludes new entrypoints, so they're not built until the dev command is restarted
changes.rebuildGroups,
changes.cachedOutput,
@@ -194,6 +244,8 @@ function reloadContentScripts(
) {
if (config.manifestVersion === 3) {
steps.forEach((step) => {
if (server.currentOutput == null) return;
const entry = step.entrypoints;
if (Array.isArray(entry) || entry.type !== 'content-script') return;
+5 -3
View File
@@ -1,5 +1,6 @@
import type { WebExtRunInstance } from 'web-ext-run';
import { ExtensionRunner } from '~/types';
import { formatDuration } from '../utils/time';
/**
* Create an `ExtensionRunner` backed by `web-ext`.
@@ -9,7 +10,7 @@ export function createWebExtRunner(): ExtensionRunner {
return {
async openBrowser(config) {
config.logger.info('Opening browser...');
const startTime = Date.now();
if (config.browser === 'firefox' && config.manifestVersion === 3) {
throw Error(
@@ -17,7 +18,7 @@ export function createWebExtRunner(): ExtensionRunner {
);
}
// Use the plugin's logger instead of web-ext's built-in one.
// Use WXT's logger instead of web-ext's built-in one.
const webExtLogger = await import('web-ext-run/util/logger');
webExtLogger.consoleStream.write = ({ level, msg, name }) => {
if (level >= ERROR_LOG_LEVEL) config.logger.error(name, msg);
@@ -61,7 +62,8 @@ export function createWebExtRunner(): ExtensionRunner {
const webExt = await import('web-ext-run');
runner = await webExt.default.cmd.run(finalConfig, options);
config.logger.success('Opened!');
const duration = Date.now() - startTime;
config.logger.success(`Opened browser in ${formatDuration(duration)}`);
},
async closeBrowser() {
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { every } from '~/core/utils/arrays';
import { every, some } from '~/core/utils/arrays';
describe('Array Utils', () => {
describe('every', () => {
@@ -15,4 +15,20 @@ describe('Array Utils', () => {
expect(every([1, 2, 1], (item) => item === 1)).toBe(false);
});
});
describe('some', () => {
it('should return true if one value returns true', () => {
const array = [1, 2, 3];
const predicate = (item: number) => item === 2;
expect(some(array, predicate)).toBe(true);
});
it('should return false if no values match', () => {
const array = [1, 2, 3];
const predicate = (item: number) => item === 4;
expect(some(array, predicate)).toBe(false);
});
});
});
@@ -47,6 +47,7 @@ describe('Entrypoint Utils', () => {
name,
outputDir,
options: {},
skipped: false,
};
const actual = getEntrypointOutputFile(entrypoint, ext);
+185 -38
View File
@@ -6,6 +6,7 @@ import {
fakeBuildOutput,
fakeEntrypoint,
fakeInternalConfig,
fakeManifestCommand,
fakeOptionsEntrypoint,
fakePopupEntrypoint,
} from '../testing/fake-objects';
@@ -52,7 +53,11 @@ describe('Manifest Utils', () => {
},
};
const actual = await generateManifest([popup], buildOutput, config);
const { manifest: actual } = await generateManifest(
[popup],
buildOutput,
config,
);
expect(actual).toMatchObject(expected);
});
@@ -79,7 +84,11 @@ describe('Manifest Utils', () => {
default_popup: 'popup.html',
};
const actual = await generateManifest([popup], buildOutput, config);
const { manifest: actual } = await generateManifest(
[popup],
buildOutput,
config,
);
expect(actual[expectedType]).toEqual(expected);
},
@@ -102,7 +111,11 @@ describe('Manifest Utils', () => {
action: config.manifest.action,
};
const actual = await generateManifest([], buildOutput, config);
const { manifest: actual } = await generateManifest(
[],
buildOutput,
config,
);
expect(actual).toMatchObject(expected);
});
@@ -131,7 +144,11 @@ describe('Manifest Utils', () => {
page: 'options.html',
};
const actual = await generateManifest([options], buildOutput, config);
const { manifest: actual } = await generateManifest(
[options],
buildOutput,
config,
);
expect(actual.options_ui).toEqual(expected);
});
@@ -149,7 +166,11 @@ describe('Manifest Utils', () => {
page: 'options.html',
};
const actual = await generateManifest([options], buildOutput, config);
const { manifest: actual } = await generateManifest(
[options],
buildOutput,
config,
);
expect(actual.options_ui).toEqual(expected);
});
@@ -179,7 +200,7 @@ describe('Manifest Utils', () => {
service_worker: 'background.js',
};
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
[background],
buildOutput,
config,
@@ -201,7 +222,7 @@ describe('Manifest Utils', () => {
scripts: ['background.js'],
};
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
[background],
buildOutput,
config,
@@ -226,7 +247,7 @@ describe('Manifest Utils', () => {
scripts: ['background.js'],
};
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
[background],
buildOutput,
config,
@@ -248,7 +269,7 @@ describe('Manifest Utils', () => {
scripts: ['background.js'],
};
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
[background],
buildOutput,
config,
@@ -274,7 +295,11 @@ describe('Manifest Utils', () => {
});
const config = fakeInternalConfig();
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.icons).toEqual({
16: 'icon-16.png',
@@ -296,7 +321,11 @@ describe('Manifest Utils', () => {
});
const config = fakeInternalConfig();
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.icons).toBeUndefined();
});
@@ -323,7 +352,11 @@ describe('Manifest Utils', () => {
},
});
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.icons).toEqual(expected);
});
@@ -339,6 +372,7 @@ describe('Manifest Utils', () => {
options: {
matches: ['*://google.com/*'],
},
skipped: false,
};
const cs1Styles: OutputAsset = {
type: 'asset',
@@ -353,6 +387,7 @@ describe('Manifest Utils', () => {
matches: ['*://google.com/*'],
runAt: 'document_end',
},
skipped: false,
};
const cs2Styles: OutputAsset = {
type: 'asset',
@@ -367,6 +402,7 @@ describe('Manifest Utils', () => {
matches: ['*://google.com/*'],
runAt: 'document_end',
},
skipped: false,
};
const cs3Styles: OutputAsset = {
type: 'asset',
@@ -381,6 +417,7 @@ describe('Manifest Utils', () => {
matches: ['*://duckduckgo.com/*'],
runAt: 'document_end',
},
skipped: false,
};
const cs4Styles: OutputAsset = {
type: 'asset',
@@ -395,6 +432,7 @@ describe('Manifest Utils', () => {
matches: ['*://google.com/*'],
world: 'MAIN',
},
skipped: false,
};
const cs5Styles: OutputAsset = {
type: 'asset',
@@ -418,7 +456,11 @@ describe('Manifest Utils', () => {
],
};
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.content_scripts).toContainEqual({
matches: ['*://google.com/*'],
@@ -454,6 +496,7 @@ describe('Manifest Utils', () => {
options: {
matches: ['*://google.com/*'],
},
skipped: false,
};
const generatedContentScript = {
matches: ['*://google.com/*'],
@@ -474,7 +517,11 @@ describe('Manifest Utils', () => {
},
});
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.content_scripts).toContainEqual(userContentScript);
expect(actual.content_scripts).toContainEqual(generatedContentScript);
@@ -493,6 +540,7 @@ describe('Manifest Utils', () => {
matches: ['*://google.com/*'],
cssInjectionMode,
},
skipped: false,
};
const styles: OutputAsset = {
type: 'asset',
@@ -509,7 +557,7 @@ describe('Manifest Utils', () => {
command: 'build',
});
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
@@ -537,6 +585,7 @@ describe('Manifest Utils', () => {
matches: ['*://google.com/*'],
cssInjectionMode,
},
skipped: false,
};
const styles: OutputAsset = {
type: 'asset',
@@ -553,7 +602,7 @@ describe('Manifest Utils', () => {
command: 'build',
});
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
@@ -578,6 +627,7 @@ describe('Manifest Utils', () => {
matches: ['*://google.com/*'],
cssInjectionMode: 'ui',
},
skipped: false,
};
const styles: OutputAsset = {
type: 'asset',
@@ -595,7 +645,7 @@ describe('Manifest Utils', () => {
manifestVersion: 3,
});
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
@@ -619,6 +669,7 @@ describe('Manifest Utils', () => {
matches: ['*://google.com/*'],
cssInjectionMode: 'ui',
},
skipped: false,
};
const styles: OutputAsset = {
type: 'asset',
@@ -636,7 +687,7 @@ describe('Manifest Utils', () => {
manifestVersion: 2,
});
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
@@ -657,6 +708,7 @@ describe('Manifest Utils', () => {
matches: ['*://play.google.com/books/*'],
cssInjectionMode: 'ui',
},
skipped: false,
};
const styles: OutputAsset = {
type: 'asset',
@@ -674,7 +726,7 @@ describe('Manifest Utils', () => {
manifestVersion: 3,
});
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
@@ -701,6 +753,7 @@ describe('Manifest Utils', () => {
matches: ['*://google.com/*'],
cssInjectionMode: 'ui',
},
skipped: false,
};
const styles: OutputAsset = {
type: 'asset',
@@ -723,7 +776,11 @@ describe('Manifest Utils', () => {
},
});
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.web_accessible_resources).toEqual([
{ resources: ['one.png'], matches: ['*://one.com/*'] },
@@ -744,6 +801,7 @@ describe('Manifest Utils', () => {
matches: ['*://google.com/*'],
cssInjectionMode: 'ui',
},
skipped: false,
};
const styles: OutputAsset = {
type: 'asset',
@@ -764,7 +822,11 @@ describe('Manifest Utils', () => {
},
});
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.web_accessible_resources).toEqual([
'one.png',
@@ -787,7 +849,11 @@ describe('Manifest Utils', () => {
author: newAuthor,
};
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual).toMatchObject(expected);
});
@@ -809,7 +875,7 @@ describe('Manifest Utils', () => {
},
});
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
@@ -835,7 +901,7 @@ describe('Manifest Utils', () => {
},
});
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
@@ -860,7 +926,7 @@ describe('Manifest Utils', () => {
},
});
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
@@ -881,7 +947,11 @@ describe('Manifest Utils', () => {
},
});
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.version).toBe('0.0.0');
expect(actual.version_name).toBeUndefined();
@@ -895,8 +965,9 @@ describe('Manifest Utils', () => {
describe('commands', () => {
const reloadCommandName = 'wxt:reload-extension';
const reloadCommand = {
description: expect.any(String),
suggested_key: {
default: 'Ctrl+E',
default: 'Alt+R',
},
};
@@ -905,21 +976,65 @@ describe('Manifest Utils', () => {
const output = fakeBuildOutput();
const entrypoints = fakeArray(fakeEntrypoint);
const actual = await generateManifest(entrypoints, output, config);
const { manifest: actual } = await generateManifest(
entrypoints,
output,
config,
);
expect(actual.commands).toMatchObject({
expect(actual.commands).toEqual({
[reloadCommandName]: reloadCommand,
});
});
it('should customize the reload commands key binding if passing a custom command', async () => {
const config = fakeInternalConfig({
command: 'serve',
dev: {
reloadCommand: 'Ctrl+E',
},
});
const output = fakeBuildOutput();
const entrypoints = fakeArray(fakeEntrypoint);
const { manifest: actual } = await generateManifest(
entrypoints,
output,
config,
);
expect(actual.commands).toEqual({
[reloadCommandName]: {
...reloadCommand,
suggested_key: {
default: 'Ctrl+E',
},
},
});
});
it("should not include the reload command when it's been disabled", async () => {
const config = fakeInternalConfig({
command: 'serve',
dev: {
reloadCommand: false,
},
});
const output = fakeBuildOutput();
const entrypoints = fakeArray(fakeEntrypoint);
const { manifest: actual } = await generateManifest(
entrypoints,
output,
config,
);
expect(actual.commands).toBeUndefined();
});
it('should not override any existing commands when adding the one to reload the extension', async () => {
const customCommandName = 'custom-command';
const customCommand = {
description: 'Some other command',
suggested_key: {
default: 'Ctrl+H',
},
};
const customCommand = fakeManifestCommand();
const config = fakeInternalConfig({
command: 'serve',
manifest: {
@@ -931,20 +1046,52 @@ describe('Manifest Utils', () => {
const output = fakeBuildOutput();
const entrypoints = fakeArray(fakeEntrypoint);
const actual = await generateManifest(entrypoints, output, config);
const { manifest: actual } = await generateManifest(
entrypoints,
output,
config,
);
expect(actual.commands).toMatchObject({
expect(actual.commands).toEqual({
[reloadCommandName]: reloadCommand,
[customCommandName]: customCommand,
});
});
it('should not include the command if there are already 4 others (the max)', async () => {
const commands = {
command1: fakeManifestCommand(),
command2: fakeManifestCommand(),
command3: fakeManifestCommand(),
command4: fakeManifestCommand(),
};
const config = fakeInternalConfig({
command: 'serve',
manifest: { commands },
});
const output = fakeBuildOutput();
const entrypoints = fakeArray(fakeEntrypoint);
const { manifest: actual, warnings } = await generateManifest(
entrypoints,
output,
config,
);
expect(actual.commands).toEqual(commands);
expect(warnings).toHaveLength(1);
});
it('should not include the command when building an extension', async () => {
const config = fakeInternalConfig({ command: 'build' });
const output = fakeBuildOutput();
const entrypoints = fakeArray(fakeEntrypoint);
const actual = await generateManifest(entrypoints, output, config);
const { manifest: actual } = await generateManifest(
entrypoints,
output,
config,
);
expect(actual.commands).toBeUndefined();
});
@@ -0,0 +1,7 @@
import { defineContentScript } from '~/sandbox';
import { faker } from '@faker-js/faker';
export default defineContentScript({
matches: [faker.string.nanoid()],
main() {},
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import {
fakeArray,
fakeContentScriptEntrypoint,
fakeEntrypoint,
fakeGenericEntrypoint,
} from '../testing/fake-objects';
import { validateEntrypoints } from '../validation';
describe('Validation Utils', () => {
describe('validateEntrypoints', () => {
it('should return no errors when there are no errors', () => {
const entrypoints = fakeArray(fakeEntrypoint);
const expected = {
errors: [],
errorCount: 0,
warningCount: 0,
};
const actual = validateEntrypoints(entrypoints);
expect(actual).toEqual(expected);
});
it('should return an error when exclude is not an array', () => {
const entrypoint = fakeGenericEntrypoint({
options: {
// @ts-expect-error
exclude: 0,
},
});
const expected = {
errors: [
{
type: 'error',
message: '`exclude` must be an array of browser names',
value: 0,
entrypoint,
},
],
errorCount: 1,
warningCount: 0,
};
const actual = validateEntrypoints([entrypoint]);
expect(actual).toEqual(expected);
});
it('should return an error when include is not an array', () => {
const entrypoint = fakeGenericEntrypoint({
options: {
// @ts-expect-error
include: 0,
},
});
const expected = {
errors: [
{
type: 'error',
message: '`include` must be an array of browser names',
value: 0,
entrypoint,
},
],
errorCount: 1,
warningCount: 0,
};
const actual = validateEntrypoints([entrypoint]);
expect(actual).toEqual(expected);
});
it("should return an error when content scripts don't have a matches", () => {
const entrypoint = fakeContentScriptEntrypoint({
options: {
// @ts-expect-error
matches: null,
},
});
const expected = {
errors: [
{
type: 'error',
message: '`matches` is required',
value: null,
entrypoint,
},
],
errorCount: 1,
warningCount: 0,
};
const actual = validateEntrypoints([entrypoint]);
expect(actual).toEqual(expected);
});
});
});
+12
View File
@@ -9,3 +9,15 @@ export function every<T>(
if (!predicate(array[i], i)) return false;
return true;
}
/**
* Returns true when any of the predicates return true;
*/
export function some<T>(
array: T[],
predicate: (item: T, index: number) => boolean,
): boolean {
for (let i = 0; i < array.length; i++)
if (predicate(array[i], i)) return true;
return false;
}
@@ -10,22 +10,15 @@ import {
fakePopupEntrypoint,
fakeOutputAsset,
fakeOutputChunk,
fakeInternalConfig,
} from '~/core/utils/testing/fake-objects';
import { BuildOutput, BuildStepOutput } from '~/types';
describe('Detect Dev Changes', () => {
describe('No changes', () => {
it("should return 'no-change' when a build hasn't finished", () => {
const actual = detectDevChanges(
[['unknown', '/path/to/file.ts']],
undefined,
);
expect(actual).toEqual({ type: 'no-change' });
});
it("should return 'no-change' when the changed file isn't used by any of the entrypoints", () => {
const change: [string, string] = ['unknown', '/some/path.ts'];
const config = fakeInternalConfig();
const changes = ['/some/path.ts'];
const currentOutput: BuildOutput = {
manifest: fakeManifest(),
publicAssets: [],
@@ -41,18 +34,64 @@ describe('Detect Dev Changes', () => {
],
};
const actual = detectDevChanges([change], currentOutput);
const actual = detectDevChanges(config, changes, currentOutput);
expect(actual).toEqual({ type: 'no-change' });
});
});
describe('wxt.config.ts', () => {
it("should return 'full-restart' when one of the changed files is the config file", () => {
const configFile = '/root/wxt.config.ts';
const config = fakeInternalConfig({
userConfigMetadata: {
configFile,
},
});
const changes = ['/root/src/public/image.svg', configFile];
const currentOutput: BuildOutput = {
manifest: fakeManifest(),
publicAssets: [],
steps: [],
};
const expected: DevModeChange = {
type: 'full-restart',
};
const actual = detectDevChanges(config, changes, currentOutput);
expect(actual).toEqual(expected);
});
});
describe('web-ext.config.ts', () => {
it("should return 'browser-restart' when one of the changed files is the config file", () => {
const runnerFile = '/root/web-ext.config.ts';
const config = fakeInternalConfig({
runnerConfig: {
configFile: runnerFile,
},
});
const changes = ['/root/src/public/image.svg', runnerFile];
const currentOutput: BuildOutput = {
manifest: fakeManifest(),
publicAssets: [],
steps: [],
};
const expected: DevModeChange = {
type: 'browser-restart',
};
const actual = detectDevChanges(config, changes, currentOutput);
expect(actual).toEqual(expected);
});
});
describe('Public Assets', () => {
it("should return 'extension-reload' without any groups to rebuild when the changed file is a public asset", () => {
const change: [string, string] = [
'unknown',
'/root/src/public/image.svg',
];
const config = fakeInternalConfig();
const changes = ['/root/src/public/image.svg'];
const asset1 = fakeOutputAsset({
fileName: 'image.svg',
});
@@ -73,7 +112,7 @@ describe('Detect Dev Changes', () => {
},
};
const actual = detectDevChanges([change], currentOutput);
const actual = detectDevChanges(config, changes, currentOutput);
expect(actual).toEqual(expected);
});
@@ -81,6 +120,7 @@ describe('Detect Dev Changes', () => {
describe('Background', () => {
it("should rebuild the background and reload the extension when the changed file in it's chunks' `moduleIds` field", () => {
const config = fakeInternalConfig();
const changedPath = '/root/utils/shared.ts';
const contentScript = fakeContentScriptEntrypoint({
inputPath: '/root/overlay.content.ts',
@@ -120,10 +160,7 @@ describe('Detect Dev Changes', () => {
rebuildGroups: [background],
};
const actual = detectDevChanges(
[['unknown', changedPath]],
currentOutput,
);
const actual = detectDevChanges(config, [changedPath], currentOutput);
expect(actual).toEqual(expected);
});
@@ -131,6 +168,7 @@ describe('Detect Dev Changes', () => {
describe('HTML Pages', () => {
it('should rebuild then reload only the effected pages', async () => {
const config = fakeInternalConfig();
const changedPath = '/root/page1/index.html';
const htmlPage1 = fakePopupEntrypoint({
inputPath: changedPath,
@@ -174,10 +212,7 @@ describe('Detect Dev Changes', () => {
rebuildGroups: [[htmlPage1, htmlPage2]],
};
const actual = detectDevChanges(
[['unknown', changedPath]],
currentOutput,
);
const actual = detectDevChanges(config, [changedPath], currentOutput);
expect(actual).toEqual(expected);
});
@@ -185,6 +220,7 @@ describe('Detect Dev Changes', () => {
describe('Content Scripts', () => {
it('should rebuild then reload only the effected content scripts', async () => {
const config = fakeInternalConfig();
const changedPath = '/root/utils/shared.ts';
const script1 = fakeContentScriptEntrypoint({
inputPath: '/root/overlay1.content/index.ts',
@@ -236,10 +272,7 @@ describe('Detect Dev Changes', () => {
rebuildGroups: [script1, script3],
};
const actual = detectDevChanges(
[['unknown', changedPath]],
currentOutput,
);
const actual = detectDevChanges(config, [changedPath], currentOutput);
expect(actual).toEqual(expected);
});
@@ -53,6 +53,7 @@ describe('findEntrypoints', () => {
defaultIcon: { '16': '/icon/16.png' },
defaultTitle: 'Default Title',
},
skipped: false,
},
],
[
@@ -72,6 +73,7 @@ describe('findEntrypoints', () => {
options: {
defaultTitle: 'Title',
},
skipped: false,
},
],
])(
@@ -103,6 +105,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'options.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
[
@@ -123,6 +126,7 @@ describe('findEntrypoints', () => {
options: {
openInTab: true,
},
skipped: false,
},
],
])(
@@ -146,6 +150,7 @@ describe('findEntrypoints', () => {
name: 'content',
inputPath: resolve(config.entrypointsDir, 'content.ts'),
outputDir: resolve(config.outDir, 'content-scripts'),
skipped: false,
},
],
[
@@ -155,6 +160,7 @@ describe('findEntrypoints', () => {
name: 'overlay',
inputPath: resolve(config.entrypointsDir, 'overlay.content.ts'),
outputDir: resolve(config.outDir, 'content-scripts'),
skipped: false,
},
],
[
@@ -164,6 +170,7 @@ describe('findEntrypoints', () => {
name: 'content',
inputPath: resolve(config.entrypointsDir, 'content/index.ts'),
outputDir: resolve(config.outDir, 'content-scripts'),
skipped: false,
},
],
[
@@ -173,6 +180,7 @@ describe('findEntrypoints', () => {
name: 'overlay',
inputPath: resolve(config.entrypointsDir, 'overlay.content/index.ts'),
outputDir: resolve(config.outDir, 'content-scripts'),
skipped: false,
},
],
[
@@ -182,6 +190,7 @@ describe('findEntrypoints', () => {
name: 'overlay',
inputPath: resolve(config.entrypointsDir, 'overlay.content.tsx'),
outputDir: resolve(config.outDir, 'content-scripts'),
skipped: false,
},
],
])(
@@ -212,6 +221,7 @@ describe('findEntrypoints', () => {
name: 'background',
inputPath: resolve(config.entrypointsDir, 'background.ts'),
outputDir: config.outDir,
skipped: false,
},
],
[
@@ -221,6 +231,7 @@ describe('findEntrypoints', () => {
name: 'background',
inputPath: resolve(config.entrypointsDir, 'background/index.ts'),
outputDir: config.outDir,
skipped: false,
},
],
])(
@@ -258,6 +269,7 @@ describe('findEntrypoints', () => {
name: 'background',
options: {},
outputDir: config.outDir,
skipped: false,
});
});
@@ -278,6 +290,7 @@ describe('findEntrypoints', () => {
name: 'injected',
inputPath: resolve(config.entrypointsDir, path),
outputDir: config.outDir,
skipped: false,
};
const options: GenericEntrypoint['options'] = {};
globMock.mockResolvedValueOnce([path]);
@@ -304,6 +317,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'sandbox.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
[
@@ -314,6 +328,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'sandbox/index.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
[
@@ -324,6 +339,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'named.sandbox.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
[
@@ -334,6 +350,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'named.sandbox/index.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
@@ -346,6 +363,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'bookmarks.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
[
@@ -356,6 +374,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'bookmarks/index.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
@@ -368,6 +387,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'history.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
[
@@ -378,6 +398,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'history/index.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
@@ -390,6 +411,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'newtab.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
[
@@ -400,6 +422,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'newtab/index.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
@@ -412,6 +435,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'sidepanel.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
[
@@ -422,6 +446,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'sidepanel/index.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
[
@@ -432,6 +457,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'named.sidepanel.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
[
@@ -442,6 +468,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'named.sidepanel/index.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
@@ -454,6 +481,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'devtools.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
[
@@ -464,6 +492,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'devtools/index.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
@@ -476,6 +505,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'onboarding.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
[
@@ -486,6 +516,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'onboarding/index.html'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
@@ -498,6 +529,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'iframe.scss'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
[
@@ -508,6 +540,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'iframe.css'),
outputDir: config.outDir,
options: {},
skipped: false,
},
],
@@ -520,6 +553,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'content.css'),
outputDir: resolve(config.outDir, 'content-scripts'),
options: {},
skipped: false,
},
],
[
@@ -530,6 +564,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'overlay.content.css'),
outputDir: resolve(config.outDir, 'content-scripts'),
options: {},
skipped: false,
},
],
[
@@ -540,6 +575,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'content/index.css'),
outputDir: resolve(config.outDir, 'content-scripts'),
options: {},
skipped: false,
},
],
[
@@ -550,6 +586,7 @@ describe('findEntrypoints', () => {
inputPath: resolve(config.entrypointsDir, 'overlay.content/index.css'),
outputDir: resolve(config.outDir, 'content-scripts'),
options: {},
skipped: false,
},
],
])('should find entrypoint for %s', async (path, expected) => {
@@ -737,4 +774,29 @@ describe('findEntrypoints', () => {
expect(entrypoints).toEqual([]);
});
});
describe('filterEntrypoints option', () => {
it('should control entrypoints accessible', async () => {
globMock.mockResolvedValue([
'options/index.html',
'popup/index.html',
'ui.content/index.ts',
'injected.content/index.ts',
]);
importEntrypointFileMock.mockResolvedValue({});
const filterEntrypoints = ['popup', 'ui'];
const config = fakeInternalConfig({
root: '/',
entrypointsDir: resolve('/src/entrypoints'),
outDir: resolve('.output'),
command: 'build',
filterEntrypoints: new Set(filterEntrypoints),
});
const entrypoints = await findEntrypoints(config);
const names = entrypoints.map((item) => item.name);
expect(names).toHaveLength(2);
expect(names).toEqual(filterEntrypoints);
});
});
});
@@ -8,6 +8,7 @@ const background: Entrypoint = {
inputPath: '/background.ts',
outputDir: '/.output/background',
options: {},
skipped: false,
};
const contentScript: Entrypoint = {
type: 'content-script',
@@ -17,6 +18,7 @@ const contentScript: Entrypoint = {
options: {
matches: ['<all_urls>'],
},
skipped: false,
};
const unlistedScript: Entrypoint = {
type: 'unlisted-script',
@@ -24,6 +26,7 @@ const unlistedScript: Entrypoint = {
inputPath: '/injected.ts',
outputDir: '/.output/injected',
options: {},
skipped: false,
};
const popup: Entrypoint = {
type: 'popup',
@@ -31,6 +34,7 @@ const popup: Entrypoint = {
inputPath: '/popup.html',
outputDir: '/.output/popup',
options: {},
skipped: false,
};
const unlistedPage: Entrypoint = {
type: 'unlisted-page',
@@ -38,6 +42,7 @@ const unlistedPage: Entrypoint = {
inputPath: '/onboarding.html',
outputDir: '/.output/onboarding',
options: {},
skipped: false,
};
const options: Entrypoint = {
type: 'options',
@@ -45,6 +50,7 @@ const options: Entrypoint = {
inputPath: '/options.html',
outputDir: '/.output/options',
options: {},
skipped: false,
};
const sandbox1: Entrypoint = {
type: 'sandbox',
@@ -52,6 +58,7 @@ const sandbox1: Entrypoint = {
inputPath: '/sandbox1.html',
outputDir: '/.output/sandbox1',
options: {},
skipped: false,
};
const sandbox2: Entrypoint = {
type: 'sandbox',
@@ -59,6 +66,7 @@ const sandbox2: Entrypoint = {
inputPath: '/sandbox2.html',
outputDir: '/.output/sandbox2',
options: {},
skipped: false,
};
const unlistedStyle: Entrypoint = {
type: 'unlisted-style',
@@ -66,6 +74,7 @@ const unlistedStyle: Entrypoint = {
inputPath: '/injected.scss',
outputDir: '/.output',
options: {},
skipped: false,
};
const contentScriptStyle: Entrypoint = {
type: 'content-script-style',
@@ -73,6 +82,7 @@ const contentScriptStyle: Entrypoint = {
inputPath: '/overlay.content.scss',
outputDir: '/.output/content-scripts',
options: {},
skipped: false,
};
describe('groupEntrypoints', () => {
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { importEntrypointFile } from '~/core/utils/building';
import { fakeInternalConfig } from '~/core/utils/testing/fake-objects';
import { resolve } from 'node:path';
import { unnormalizePath } from '../../paths';
const entrypointPath = (filename: string) =>
resolve('src/core/utils/__tests__/test-entrypoints', filename);
@@ -38,4 +39,15 @@ describe('importEntrypointFile', () => {
expect(actual).toBeUndefined();
});
it('should throw a custom error message when an imported variable is used before main', async () => {
const filePath = unnormalizePath(
'../src/core/utils/__tests__/test-entrypoints/imported-option.ts',
);
await expect(() =>
importEntrypointFile(entrypointPath('imported-option.ts'), config),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: ${filePath}: Cannot use imported variable "faker" outside the main function. See https://wxt.dev/guide/entrypoints.html#side-effects]`,
);
});
});
+30 -9
View File
@@ -2,10 +2,11 @@ import {
BuildOutput,
BuildStepOutput,
EntrypointGroup,
InternalConfig,
OutputAsset,
OutputFile,
} from '~/types';
import { every } from '~/core/utils/arrays';
import { every, some } from '~/core/utils/arrays';
import { normalizePath } from '~/core/utils/paths';
/**
@@ -29,10 +30,21 @@ import { normalizePath } from '~/core/utils/paths';
* - Config file changed (wxt.config.ts, .env, web-ext.config.ts, etc)
*/
export function detectDevChanges(
changedFiles: [event: string, path: string][],
currentOutput: BuildOutput | undefined,
config: InternalConfig,
changedFiles: string[],
currentOutput: BuildOutput,
): DevModeChange {
if (currentOutput == null) return { type: 'no-change' };
const isConfigChange = some(
changedFiles,
(file) => file === config.userConfigMetadata.configFile,
);
if (isConfigChange) return { type: 'full-restart' };
const isRunnerChange = some(
changedFiles,
(file) => file === config.runnerConfig.configFile,
);
if (isRunnerChange) return { type: 'browser-restart' };
const changedSteps = new Set(
changedFiles.flatMap((changedFile) =>
@@ -69,7 +81,7 @@ export function detectDevChanges(
const isOnlyHtmlChanges =
changedFiles.length > 0 &&
every(changedFiles, ([_, file]) => file.endsWith('.html'));
every(changedFiles, (file) => file.endsWith('.html'));
if (isOnlyHtmlChanges) {
return {
type: 'html-reload',
@@ -104,11 +116,11 @@ export function detectDevChanges(
* For a single change, return all the step of the build output that were effected by it.
*/
function findEffectedSteps(
changedFile: [event: string, path: string],
changedFile: string,
currentOutput: BuildOutput,
): DetectedChange[] {
const changes: DetectedChange[] = [];
const changedPath = normalizePath(changedFile[1]);
const changedPath = normalizePath(changedFile);
const isChunkEffected = (chunk: OutputFile): boolean =>
// If it's an HTML file with the same path, is is effected because HTML files need to be pre-rendered
@@ -139,8 +151,9 @@ export type DevModeChange =
| NoChange
| HtmlReload
| ExtensionReload
| ContentScriptReload;
// | BrowserRestart
| ContentScriptReload
| FullRestart
| BrowserRestart;
interface NoChange {
type: 'no-change';
@@ -157,6 +170,14 @@ interface RebuildChange {
cachedOutput: BuildOutput;
}
interface FullRestart {
type: 'full-restart';
}
interface BrowserRestart {
type: 'browser-restart';
}
interface HtmlReload extends RebuildChange {
type: 'html-reload';
}
+39 -7
View File
@@ -24,6 +24,7 @@ import {
} from '~/core/utils/entrypoints';
import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '~/core/utils/constants';
import { CSS_EXTENSIONS_PATTERN } from '~/core/utils/paths';
import pc from 'picocolors';
/**
* Return entrypoints and their configuration by looking through the project's files.
@@ -48,7 +49,14 @@ export async function findEntrypoints(
);
if (matchingGlob) {
const type = PATH_GLOB_TO_TYPE_MAP[matchingGlob];
results.push({ name, inputPath, type });
results.push({
name,
inputPath,
type,
skipped:
config.filterEntrypoints != null &&
!config.filterEntrypoints.has(name),
});
}
return results;
}, []);
@@ -106,11 +114,22 @@ export async function findEntrypoints(
inputPath: VIRTUAL_NOOP_BACKGROUND_MODULE_ID,
name: 'background',
type: 'background',
skipped: false,
}),
);
}
config.logger.debug('All entrypoints:', entrypoints);
const skippedEntrypointNames = entrypointInfos
.filter((item) => item.skipped)
.map((item) => item.name);
if (skippedEntrypointNames.length) {
config.logger.warn(
`Filter excluded the following entrypoints:\n${skippedEntrypointNames
.map((item) => `${pc.dim('-')} ${pc.cyan(item)}`)
.join('\n')}`,
);
}
const targetEntrypoints = entrypoints.filter((entry) => {
const { include, exclude } = entry.options;
if (include?.length && exclude?.length) {
@@ -125,6 +144,9 @@ export async function findEntrypoints(
if (include?.length && !exclude?.length) {
return include.includes(config.browser);
}
if (skippedEntrypointNames.includes(entry.name)) {
return false;
}
return true;
});
@@ -136,6 +158,10 @@ interface EntrypointInfo {
name: string;
inputPath: string;
type: Entrypoint['type'];
/**
* @default false
*/
skipped: boolean;
}
function preventDuplicateEntrypointNames(
@@ -202,7 +228,7 @@ function getHtmlBaseOptions(document: Document): BaseEntrypointOptions {
*/
async function getPopupEntrypoint(
config: InternalConfig,
{ inputPath, name }: EntrypointInfo,
{ inputPath, name, skipped }: EntrypointInfo,
): Promise<PopupEntrypoint> {
const content = await fs.readFile(inputPath, 'utf-8');
const { document } = parseHTML(content);
@@ -247,6 +273,7 @@ async function getPopupEntrypoint(
options,
inputPath,
outputDir: config.outDir,
skipped,
};
}
@@ -256,7 +283,7 @@ async function getPopupEntrypoint(
*/
async function getOptionsEntrypoint(
config: InternalConfig,
{ inputPath, name }: EntrypointInfo,
{ inputPath, name, skipped }: EntrypointInfo,
): Promise<OptionsEntrypoint> {
const content = await fs.readFile(inputPath, 'utf-8');
const { document } = parseHTML(content);
@@ -290,6 +317,7 @@ async function getOptionsEntrypoint(
options,
inputPath,
outputDir: config.outDir,
skipped,
};
}
@@ -299,7 +327,7 @@ async function getOptionsEntrypoint(
*/
async function getUnlistedPageEntrypoint(
config: InternalConfig,
{ inputPath, name }: EntrypointInfo,
{ inputPath, name, skipped }: EntrypointInfo,
): Promise<GenericEntrypoint> {
const content = await fs.readFile(inputPath, 'utf-8');
const { document } = parseHTML(content);
@@ -310,6 +338,7 @@ async function getUnlistedPageEntrypoint(
inputPath,
outputDir: config.outDir,
options: getHtmlBaseOptions(document),
skipped,
};
}
@@ -319,7 +348,7 @@ async function getUnlistedPageEntrypoint(
*/
async function getUnlistedScriptEntrypoint(
config: InternalConfig,
{ inputPath, name }: EntrypointInfo,
{ inputPath, name, skipped }: EntrypointInfo,
): Promise<GenericEntrypoint> {
const defaultExport = await importEntrypointFile<UnlistedScriptDefinition>(
inputPath,
@@ -338,6 +367,7 @@ async function getUnlistedScriptEntrypoint(
inputPath,
outputDir: config.outDir,
options,
skipped,
};
}
@@ -346,7 +376,7 @@ async function getUnlistedScriptEntrypoint(
*/
async function getBackgroundEntrypoint(
config: InternalConfig,
{ inputPath, name }: EntrypointInfo,
{ inputPath, name, skipped }: EntrypointInfo,
): Promise<BackgroundEntrypoint> {
let options: Omit<BackgroundDefinition, 'main'> = {};
if (inputPath !== VIRTUAL_NOOP_BACKGROUND_MODULE_ID) {
@@ -372,6 +402,7 @@ async function getBackgroundEntrypoint(
type: resolvePerBrowserOption(options.type, config.browser),
persistent: resolvePerBrowserOption(options.persistent, config.browser),
},
skipped,
};
}
@@ -380,7 +411,7 @@ async function getBackgroundEntrypoint(
*/
async function getContentScriptEntrypoint(
config: InternalConfig,
{ inputPath, name }: EntrypointInfo,
{ inputPath, name, skipped }: EntrypointInfo,
): Promise<ContentScriptEntrypoint> {
const { main: _, ...options } =
await importEntrypointFile<ContentScriptDefinition>(inputPath, config);
@@ -395,6 +426,7 @@ async function getContentScriptEntrypoint(
inputPath,
outputDir: resolve(config.outDir, CONTENT_SCRIPT_OUT_DIR),
options,
skipped,
};
}
@@ -79,8 +79,10 @@ import "wxt/browser";
declare module "wxt/browser" {
export type PublicPath =
{{ union }}
type HtmlPublicPath = Extract<PublicPath, \`\${string}.html\`>
export interface WxtRuntime extends Runtime.Static {
getURL(path: PublicPath): string;
getURL(path: \`\${HtmlPublicPath}\${string}\`): string;
}
}
`;
+16 -2
View File
@@ -12,7 +12,7 @@ import {
import path from 'node:path';
import { createFsCache } from '~/core/utils/cache';
import consola, { LogLevels } from 'consola';
import { craeteViteBuilder } from '~/core/builders/vite';
import { createViteBuilder } from '~/core/builders/vite';
import defu from 'defu';
import { NullablyRequired } from '../types';
@@ -72,10 +72,14 @@ export async function getInternalConfig(
srcDir,
mergedConfig.entrypointsDir ?? 'entrypoints',
);
const filterEntrypoints = !!mergedConfig.filterEntrypoints?.length
? new Set(mergedConfig.filterEntrypoints)
: undefined;
const publicDir = path.resolve(srcDir, mergedConfig.publicDir ?? 'public');
const typesDir = path.resolve(wxtDir, 'types');
const outBaseDir = path.resolve(root, mergedConfig.outDir ?? '.output');
const outDir = path.resolve(outBaseDir, `${browser}-mv${manifestVersion}`);
const reloadCommand = mergedConfig.dev?.reloadCommand ?? 'Alt+R';
const runnerConfig = await loadConfig<ExtensionRunnerConfig>({
name: 'web-ext',
@@ -101,6 +105,7 @@ export async function getInternalConfig(
command,
debug,
entrypointsDir,
filterEntrypoints,
env,
fsCache: createFsCache(wxtDir),
imports: mergedConfig.imports ?? {},
@@ -132,9 +137,12 @@ export async function getInternalConfig(
mergedConfig.experimental?.includeBrowserPolyfill ?? true,
},
server,
dev: {
reloadCommand,
},
};
const builder = await craeteViteBuilder(
const builder = await createViteBuilder(
inlineConfig,
userConfig,
finalConfig,
@@ -191,6 +199,8 @@ function mergeInlineConfig(
configFile: inlineConfig.configFile,
debug: inlineConfig.debug ?? userConfig.debug,
entrypointsDir: inlineConfig.entrypointsDir ?? userConfig.entrypointsDir,
filterEntrypoints:
inlineConfig.filterEntrypoints ?? userConfig.filterEntrypoints,
imports,
logger: inlineConfig.logger ?? userConfig.logger,
manifest,
@@ -215,6 +225,10 @@ function mergeInlineConfig(
},
vite: undefined,
transformManifest: undefined,
dev: {
...userConfig.dev,
...inlineConfig.dev,
},
};
}
+12 -3
View File
@@ -2,7 +2,7 @@ import createJITI, { TransformOptions as JitiTransformOptions } from 'jiti';
import { InternalConfig } from '~/types';
import { createUnimport } from 'unimport';
import fs from 'fs-extra';
import { resolve } from 'node:path';
import { relative, resolve } from 'node:path';
import { getUnimportOptions } from '~/core/utils/unimport';
import { removeProjectImportStatements } from '~/core/utils/strings';
import { normalizePath } from '~/core/utils/paths';
@@ -89,8 +89,17 @@ export async function importEntrypointFile<T>(
const res = await jiti(path);
return res.default;
} catch (err) {
config.logger.error(err);
throw err;
if (err instanceof ReferenceError) {
// "XXX is not defined" - usually due to WXT removing imports
const variableName = err.message.replace(' is not defined', '');
const filePath = relative(config.root, path);
throw Error(
`${filePath}: Cannot use imported variable "${variableName}" outside the main function. See https://wxt.dev/guide/entrypoints.html#side-effects`,
{ cause: err },
);
} else {
throw err;
}
}
}
+65 -2
View File
@@ -1,5 +1,5 @@
import { findEntrypoints } from './find-entrypoints';
import { InternalConfig, BuildOutput } from '~/types';
import { InternalConfig, BuildOutput, Entrypoint } from '~/types';
import pc from 'picocolors';
import fs from 'fs-extra';
import { groupEntrypoints } from './group-entrypoints';
@@ -8,6 +8,15 @@ import { printBuildSummary } from '~/core/utils/log';
import glob from 'fast-glob';
import { unnormalizePath } from '~/core/utils/paths';
import { rebuild } from './rebuild';
import managePath from 'manage-path';
import { resolve, relative } from 'node:path';
import {
ValidationError,
ValidationResult,
ValidationResults,
validateEntrypoints,
} from '../validation';
import consola from 'consola';
/**
* Builds the extension based on an internal config. No more config discovery is performed, the
@@ -37,8 +46,24 @@ export async function internalBuild(
const entrypoints = await findEntrypoints(config);
config.logger.debug('Detected entrypoints:', entrypoints);
const validationResults = validateEntrypoints(entrypoints);
if (validationResults.errorCount + validationResults.warningCount > 0) {
printValidationResults(config, validationResults);
}
if (validationResults.errorCount > 0) {
throw new ValidationError(`Entrypoint validation failed`, {
cause: validationResults,
});
}
const groups = groupEntrypoints(entrypoints);
const { output } = await rebuild(config, groups, undefined);
const { output, warnings } = await rebuild(
config,
entrypoints,
groups,
undefined,
);
// Post-build
await printBuildSummary(
@@ -48,6 +73,10 @@ export async function internalBuild(
config,
);
for (const warning of warnings) {
config.logger.warn(...warning);
}
if (config.analysis.enabled) {
await combineAnalysisStats(config);
config.logger.info(
@@ -66,6 +95,10 @@ async function combineAnalysisStats(config: InternalConfig): Promise<void> {
});
const absolutePaths = unixFiles.map(unnormalizePath);
const alterPath = managePath(process.env);
// Add subdependency path for PNPM shamefully-hoist=false
alterPath.push(resolve(config.root, 'node_modules/wxt/node_modules/.bin'));
await execaCommand(
`rollup-plugin-visualizer ${absolutePaths.join(' ')} --template ${
config.analysis.template
@@ -73,3 +106,33 @@ async function combineAnalysisStats(config: InternalConfig): Promise<void> {
{ cwd: config.root, stdio: 'inherit' },
);
}
function printValidationResults(
config: InternalConfig,
{ errorCount, errors, warningCount }: ValidationResults,
) {
(errorCount > 0 ? config.logger.error : config.logger.warn)(
`Entrypoint validation failed: ${errorCount} error${
errorCount === 1 ? '' : 's'
}, ${warningCount} warning${warningCount === 1 ? '' : 's'}`,
);
const cwd = process.cwd();
const entrypointErrors = errors.reduce((map, error) => {
const entryErrors = map.get(error.entrypoint) ?? [];
entryErrors.push(error);
map.set(error.entrypoint, entryErrors);
return map;
}, new Map<Entrypoint, ValidationResult[]>());
Array.from(entrypointErrors.entries()).forEach(([entrypoint, errors]) => {
consola.log(relative(cwd, entrypoint.inputPath));
console.log();
errors.forEach((err) => {
const type = err.type === 'error' ? pc.red('ERROR') : pc.yellow('WARN');
const recieved = pc.dim(`(recieved: ${JSON.stringify(err.value)})`);
consola.log(` - ${type} ${err.message} ${recieved}`);
});
console.log();
});
}
+21 -9
View File
@@ -1,6 +1,10 @@
import type { Manifest } from '~/browser';
import { BuildOutput, EntrypointGroup, InternalConfig } from '~/types';
import { findEntrypoints } from './find-entrypoints';
import {
BuildOutput,
Entrypoint,
EntrypointGroup,
InternalConfig,
} from '~/types';
import { generateTypesDir } from './generate-wxt-dir';
import { buildEntrypoints } from './build-entrypoints';
import { generateManifest, writeManifest } from '~/core/utils/manifest';
@@ -14,20 +18,30 @@ import { generateManifest, writeManifest } from '~/core/utils/manifest';
* 2. Build the `entrypointGroups` (and copies public files)
* 3. Generate the latest manifest for all entrypoints
* 4. Write the new manifest to the file system
*
* @param config Internal config containing all the project information.
* @param allEntrypoints List of entrypoints used to generate the types inside .wxt directory.
* @param entrypointGroups The list of entrypoint groups to build.
* @param existingOutput The previous output to combine the rebuild results into. An emptry array if
* this is the first build.
*/
export async function rebuild(
config: InternalConfig,
allEntrypoints: Entrypoint[],
entrypointGroups: EntrypointGroup[],
existingOutput: Omit<BuildOutput, 'manifest'> = {
steps: [],
publicAssets: [],
},
): Promise<{ output: BuildOutput; manifest: Manifest.WebExtensionManifest }> {
): Promise<{
output: BuildOutput;
manifest: Manifest.WebExtensionManifest;
warnings: any[][];
}> {
const { default: ora } = await import('ora');
const spinner = ora(`Preparing...`).start();
// Update types directory with new files and types
const allEntrypoints = await findEntrypoints(config);
await generateTypesDir(allEntrypoints, config).catch((err) => {
config.logger.warn('Failed to update .wxt directory:', err);
// Throw the error if doing a regular build, don't for dev mode.
@@ -41,11 +55,8 @@ export async function rebuild(
publicAssets: [...existingOutput.publicAssets, ...newOutput.publicAssets],
};
const newManifest = await generateManifest(
allEntrypoints,
mergedOutput,
config,
);
const { manifest: newManifest, warnings: manifestWarnings } =
await generateManifest(allEntrypoints, mergedOutput, config);
const finalOutput: BuildOutput = {
manifest: newManifest,
...newOutput,
@@ -67,5 +78,6 @@ export async function rebuild(
],
},
manifest: newManifest,
warnings: manifestWarnings,
};
}
+23 -12
View File
@@ -54,7 +54,8 @@ export async function generateManifest(
entrypoints: Entrypoint[],
buildOutput: Omit<BuildOutput, 'manifest'>,
config: InternalConfig,
): Promise<Manifest.WebExtensionManifest> {
): Promise<{ manifest: Manifest.WebExtensionManifest; warnings: any[][] }> {
const warnings: any[][] = [];
const pkg = await getPackageJson(config);
let versionName =
@@ -75,16 +76,6 @@ export async function generateManifest(
short_name: pkg?.shortName,
icons: discoverIcons(buildOutput),
};
if (config.command === 'serve') {
baseManifest.commands = {
'wxt:reload-extension': {
description: 'Reload the extension during development',
suggested_key: {
default: 'Ctrl+E',
},
},
};
}
const userManifest = config.manifest;
const manifest = defu(
@@ -92,6 +83,23 @@ export async function generateManifest(
baseManifest,
) as Manifest.WebExtensionManifest;
// Add reload command in dev mode
if (config.command === 'serve' && config.dev.reloadCommand) {
if (manifest.commands && Object.keys(manifest.commands).length >= 4) {
warnings.push([
"Extension already has 4 registered commands, WXT's reload command is disabled",
]);
} else {
manifest.commands ??= {};
manifest.commands['wxt:reload-extension'] = {
description: 'Reload the extension during development',
suggested_key: {
default: config.dev.reloadCommand,
},
};
}
}
// Apply the final version fields after merging the user manifest
manifest.version = version;
manifest.version_name =
@@ -117,7 +125,10 @@ export async function generateManifest(
);
}
return finalManifest;
return {
manifest: finalManifest,
warnings,
};
}
/**
+23 -6
View File
@@ -4,7 +4,7 @@
import { resolve } from 'path';
import { faker } from '@faker-js/faker';
import merge from 'lodash.merge';
import type { Manifest } from '~/browser';
import { Commands, type Manifest } from '~/browser';
import {
FsCache,
InternalConfig,
@@ -59,7 +59,7 @@ export const fakeContentScriptEntrypoint =
name: faker.string.alpha(),
options: {
matches: [],
matchAboutBlank: faker.helpers.arrayElement([true, false, undefined]),
matchAboutBlank: faker.datatype.boolean(),
matchOriginAsFallback: faker.helpers.arrayElement([
true,
false,
@@ -73,6 +73,7 @@ export const fakeContentScriptEntrypoint =
]),
},
outputDir: fakeDir('.output'),
skipped: faker.datatype.boolean(),
}));
export const fakeBackgroundEntrypoint = fakeObjectCreator<BackgroundEntrypoint>(
@@ -81,10 +82,11 @@ export const fakeBackgroundEntrypoint = fakeObjectCreator<BackgroundEntrypoint>(
inputPath: 'entrypoints/background.ts',
name: 'background',
options: {
persistent: faker.helpers.arrayElement([true, false, undefined]),
persistent: faker.datatype.boolean(),
type: faker.helpers.maybe(() => 'module'),
},
outputDir: fakeDir('.output'),
skipped: faker.datatype.boolean(),
}),
);
@@ -95,6 +97,7 @@ export const fakeUnlistedScriptEntrypoint =
name: faker.string.alpha(),
outputDir: fakeDir('.output'),
options: {},
skipped: faker.datatype.boolean(),
}));
export const fakeOptionsEntrypoint = fakeObjectCreator<OptionsEntrypoint>(
@@ -104,10 +107,11 @@ export const fakeOptionsEntrypoint = fakeObjectCreator<OptionsEntrypoint>(
name: 'options',
outputDir: fakeDir('.output'),
options: {
browserStyle: faker.helpers.arrayElement([true, false, undefined]),
chromeStyle: faker.helpers.arrayElement([true, false, undefined]),
openInTab: faker.helpers.arrayElement([true, false, undefined]),
browserStyle: faker.datatype.boolean(),
chromeStyle: faker.datatype.boolean(),
openInTab: faker.datatype.boolean(),
},
skipped: faker.datatype.boolean(),
}),
);
@@ -134,6 +138,7 @@ export const fakePopupEntrypoint = fakeObjectCreator<PopupEntrypoint>(() => ({
undefined,
]),
},
skipped: faker.datatype.boolean(),
}));
export const fakeGenericEntrypoint = fakeObjectCreator<GenericEntrypoint>(
@@ -152,6 +157,7 @@ export const fakeGenericEntrypoint = fakeObjectCreator<GenericEntrypoint>(
name: faker.string.alpha(),
outputDir: fakeDir('.output'),
options: {},
skipped: faker.datatype.boolean(),
}),
);
@@ -238,6 +244,9 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
includeBrowserPolyfill: true,
},
builder: mock(),
dev: {
reloadCommand: 'Alt+R',
},
};
});
@@ -251,3 +260,11 @@ export const fakeBuildStepOutput = fakeObjectCreator<BuildStepOutput>(() => ({
chunks: fakeArray(fakeOutputChunk),
entrypoints: fakeArray(fakeEntrypoint),
}));
export const fakeManifestCommand = fakeObjectCreator<Commands.Command>(() => ({
description: faker.string.sample(),
shortcut: `${faker.helpers.arrayElement(['ctrl', 'alt'])}+${faker.number.int({
min: 0,
max: 9,
})}`,
}));
+86
View File
@@ -0,0 +1,86 @@
import { ContentScriptEntrypoint, Entrypoint } from '~/types';
export function validateEntrypoints(
entrypoints: Entrypoint[],
): ValidationResults {
const errors = entrypoints.flatMap((entrypoint) => {
switch (entrypoint.type) {
case 'content-script':
return validateContentScriptEntrypoint(entrypoint);
default:
return validateBaseEntrypoint(entrypoint);
}
});
let errorCount = 0;
let warningCount = 0;
for (const err of errors) {
if (err.type === 'warning') warningCount++;
else errorCount++;
}
return {
errors,
errorCount,
warningCount,
};
}
function validateContentScriptEntrypoint(
definition: ContentScriptEntrypoint,
): ValidationResult[] {
const errors = validateBaseEntrypoint(definition);
if (definition.options.matches == null) {
errors.push({
type: 'error',
message: '`matches` is required',
value: definition.options.matches,
entrypoint: definition,
});
}
return errors;
}
function validateBaseEntrypoint(definition: Entrypoint): ValidationResult[] {
const errors: ValidationResult[] = [];
if (
definition.options.exclude != null &&
!Array.isArray(definition.options.exclude)
) {
errors.push({
type: 'error',
message: '`exclude` must be an array of browser names',
value: definition.options.exclude,
entrypoint: definition,
});
}
if (
definition.options.include != null &&
!Array.isArray(definition.options.include)
) {
errors.push({
type: 'error',
message: '`include` must be an array of browser names',
value: definition.options.include,
entrypoint: definition,
});
}
return errors;
}
export interface ValidationResult {
type: 'warning' | 'error';
message: string;
entrypoint: Entrypoint;
value: any;
}
export interface ValidationResults {
errors: ValidationResult[];
errorCount: number;
warningCount: number;
}
export class ValidationError extends Error {}
+43 -2
View File
@@ -31,6 +31,12 @@ export interface InlineConfig {
* @default "${config.srcDir}/entrypoints"
*/
entrypointsDir?: string;
/**
* A list of entrypoint names (`"popup"`, `"options"`, etc.) to build. Will speed up the build if
* your extension has lots of entrypoints, and you don't need to build all of them to develop a
* feature.
*/
filterEntrypoints?: string[];
/**
* Output directory that stored build folders and ZIPs.
*
@@ -223,6 +229,24 @@ export interface InlineConfig {
*/
includeBrowserPolyfill?: boolean;
};
/**
* Config effecting dev mode only.
*/
dev?: {
/**
* Controls whether a custom keyboard shortcut command, `Alt+R`, is added during dev mode to
* quickly reload the extension.
*
* If false, the shortcut is not added during development.
*
* If set to a custom string, you can override the key combo used. See
* [Chrome's command docs](https://developer.chrome.com/docs/extensions/reference/api/commands)
* for available options.
*
* @default "Alt+R"
*/
reloadCommand?: string | false;
};
}
// TODO: Extract to @wxt/vite-builder and use module augmentation to include the vite field
@@ -284,16 +308,24 @@ export interface BuildStepOutput {
}
export interface WxtDevServer
extends Omit<WxtBuilderServer, 'listen'>,
extends Omit<WxtBuilderServer, 'listen' | 'close'>,
ServerInfo {
/**
* Stores the current build output of the server.
*/
currentOutput: BuildOutput;
currentOutput: BuildOutput | undefined;
/**
* Start the server.
*/
start(): Promise<void>;
/**
* Stop the server.
*/
stop(): Promise<void>;
/**
* Close the browser, stop the server, rebuild the entire extension, and start the server again.
*/
restart(): Promise<void>;
/**
* Transform the HTML for dev mode.
*/
@@ -326,6 +358,10 @@ export interface WxtDevServer
reloadContentScript: (
contentScript: Omit<Scripting.RegisteredContentScript, 'id'>,
) => void;
/**
* Grab the latest runner config and restart the browser.
*/
restartBrowser: () => void;
}
export type TargetBrowser = string;
@@ -377,6 +413,7 @@ export interface BaseEntrypoint {
*/
outputDir: string;
options: BaseEntrypointOptions;
skipped: boolean;
}
export interface GenericEntrypoint extends BaseEntrypoint {
@@ -667,6 +704,10 @@ export interface WxtBuilderServer {
* Start the server.
*/
listen(): Promise<void>;
/**
* Stop the server.
*/
close(): Promise<void>;
/**
* Transform the HTML for dev mode.
*/
+4
View File
@@ -21,6 +21,7 @@ export interface InternalConfig {
wxtDir: string;
typesDir: string;
entrypointsDir: string;
filterEntrypoints?: Set<string>;
outBaseDir: string;
outDir: string;
debug: boolean;
@@ -56,6 +57,9 @@ export interface InternalConfig {
includeBrowserPolyfill: boolean;
};
builder: WxtBuilder;
dev: {
reloadCommand: string | false;
};
}
export interface FsCache {
+4
View File
@@ -50,6 +50,10 @@ declare module 'web-ext-run/util/logger' {
export const consoleStream: IConsoleStream;
}
declare module 'manage-path' {
export default function managePath(env: object): string[];
}
declare module 'wxt/browser' {
// Overridden when types are generated per project
export type PublicPath = string;
+1 -1
View File
@@ -23,6 +23,6 @@
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"wxt": "^0.13.0"
"wxt": "^0.14.0"
}
}
+1 -1
View File
@@ -20,6 +20,6 @@
"devDependencies": {
"typescript": "^5.3.3",
"vite-plugin-solid": "^2.8.0",
"wxt": "^0.13.0"
"wxt": "^0.14.0"
}
}
+1 -1
View File
@@ -20,6 +20,6 @@
"svelte-check": "^3.6.2",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"wxt": "^0.13.0"
"wxt": "^0.14.0"
}
}
+1 -1
View File
@@ -16,6 +16,6 @@
},
"devDependencies": {
"typescript": "^5.3.3",
"wxt": "^0.13.0"
"wxt": "^0.14.0"
}
}
+1 -1
View File
@@ -21,6 +21,6 @@
"@vitejs/plugin-vue": "^5.0.1",
"typescript": "^5.3.3",
"vue-tsc": "^1.8.27",
"wxt": "^0.13.0"
"wxt": "^0.14.0"
}
}