Compare commits

..

34 Commits

Author SHA1 Message Date
GitHub Actions 5e60f37ec5 chore(release): v0.15.0 2024-01-29 17:18:38 +00:00
Aaron 6f901b32eb fix: Enable inline sourcemaps by default during development (#382) 2024-01-29 11:15:57 -06:00
Aaron 927aca612e fix!: Move browser constants to import.meta.env (#380)
BREAKING CHANGE: Renamed undocumented constants:

- `__BROWSER__` → `import.meta.env.BROWSER`
- `__COMMAND__` → `import.meta.env.COMMAND`
- `__MANIFEST_VERSION__` → `import.meta.env.MANIFEST_VERSION`
- `__IS_CHROME__` → `import.meta.env.CHROME`
- `__IS_FIREFOX__` → `import.meta.env.FIREFOX`
- `__IS_SAFARI__` → `import.meta.env.SAFARI`
- `__IS_EDGE__` → `import.meta.env.EDGE`
- `__IS_OPERA__` → `import.meta.env.OPERA`
2024-01-29 11:14:18 -06:00
Nenad Novaković 2dcdae91eb feat(zip)!: add includeSources and rename ignoredSources to excludeSources (#378)
Co-authored-by: Aaron <aaronklinker1@gmail.com>

BREAKING CHANGE: Renamed `ignoredSources` to `excludeSources`
2024-01-29 11:13:39 -06:00
Aaron Klinker eb758bdeb8 chore: Update contributor docs
See https://github.com/wxt-dev/wxt/pull/378\#issuecomment-1915064721
2024-01-29 10:59:54 -06:00
Aaron 5acf51af9d fix: Generate missing sourcemap in wxt:unimport plugin (#381) 2024-01-29 10:30:42 -06:00
Aaron Klinker f9718a1b71 docs: Fix typo 2024-01-28 09:42:45 -06:00
GitHub Actions cf7f23d4c3 chore(release): v0.14.7 2024-01-27 17:31:33 +00:00
Aaron Klinker 62ecb6f41e fix: Upgrade web-ext-run
This closes #324
2024-01-27 11:25:49 -06:00
Aaron Klinker fc45c37f22 docs: Add missing await to createShadowRootUi examples
This closes #374
2024-01-27 11:02:15 -06:00
Aaron Klinker 68874e607e docs: Document defined constants for the build target 2024-01-27 10:56:14 -06:00
Aaron Klinker 35865adcb2 fix(storage): Throw better error message when importing outside a extension environment
This closes #371
2024-01-27 00:26:35 -06:00
Aaron Klinker 3b63a51475 fix: Improve error messages when importing and building entrypoints 2024-01-27 00:20:48 -06:00
東奈比 d32e59e38f docs: fix incorrect sample code (#372) 2024-01-26 10:26:10 -06:00
Aaron Klinker dab8efa140 docs: Add matches to content script examples 2024-01-24 07:58:47 -06:00
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
76 changed files with 1883 additions and 1227 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
+137
View File
@@ -1,5 +1,142 @@
# Changelog
## v0.15.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.7...v0.15.0)
### 🚀 Enhancements
- **zip:** ⚠️ Add `includeSources` and rename `ignoredSources` to `excludeSources` ([#378](https://github.com/wxt-dev/wxt/pull/378))
### 🩹 Fixes
- Generate missing sourcemap in `wxt:unimport` plugin ([#381](https://github.com/wxt-dev/wxt/pull/381))
- ⚠️ Move browser constants to `import.meta.env` ([#380](https://github.com/wxt-dev/wxt/pull/380))
- Enable inline sourcemaps by default during development ([#382](https://github.com/wxt-dev/wxt/pull/382))
### 📖 Documentation
- Fix typo ([f9718a1](https://github.com/wxt-dev/wxt/commit/f9718a1))
### 🏡 Chore
- Update contributor docs ([eb758bd](https://github.com/wxt-dev/wxt/commit/eb758bd))
#### ⚠️ Breaking Changes
- **zip:** ⚠️ Add `includeSources` and rename `ignoredSources` to `excludeSources` ([#378](https://github.com/wxt-dev/wxt/pull/378))
- ⚠️ Move browser constants to `import.meta.env` ([#380](https://github.com/wxt-dev/wxt/pull/380))
### ❤️ Contributors
- Nenad Novaković
## v0.14.7
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.6...v0.14.7)
### 🩹 Fixes
- Improve error messages when importing and building entrypoints ([3b63a51](https://github.com/wxt-dev/wxt/commit/3b63a51))
- **storage:** Throw better error message when importing outside a extension environment ([35865ad](https://github.com/wxt-dev/wxt/commit/35865ad))
- Upgrade `web-ext-run` ([62ecb6f](https://github.com/wxt-dev/wxt/commit/62ecb6f))
### 📖 Documentation
- Add `matches` to content script examples ([dab8efa](https://github.com/wxt-dev/wxt/commit/dab8efa))
- Fix incorrect sample code ([#372](https://github.com/wxt-dev/wxt/pull/372))
- Document defined constants for the build target ([68874e6](https://github.com/wxt-dev/wxt/commit/68874e6))
- Add missing `await` to `createShadowRootUi` examples ([fc45c37](https://github.com/wxt-dev/wxt/commit/fc45c37))
### ❤️ Contributors
- 東奈比 ([@dongnaebi](http://github.com/dongnaebi))
## v0.14.6
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.5...v0.14.6)
### 🚀 Enhancements
- Restart dev mode when saving config ([#365](https://github.com/wxt-dev/wxt/pull/365))
- Add basic validation for entrypoint options ([#368](https://github.com/wxt-dev/wxt/pull/368))
### 🩹 Fixes
- Add subdependency bin directory so `wxt build --analyze` works with PNPM ([#363](https://github.com/wxt-dev/wxt/pull/363))
- Sort build output files naturally ([#364](https://github.com/wxt-dev/wxt/pull/364))
### 🤖 CI
- Check for type errors in demo before building ([4b005b4](https://github.com/wxt-dev/wxt/commit/4b005b4))
## v0.14.5
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.4...v0.14.5)
### 🚀 Enhancements
- Add `dev.reloadCommand` config ([#362](https://github.com/wxt-dev/wxt/pull/362))
### 🩹 Fixes
- Disable reload dev command when 4 commands are already registered ([#361](https://github.com/wxt-dev/wxt/pull/361))
## v0.14.4
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.3...v0.14.4)
### 🩹 Fixes
- Allow requiring built-in node modules from ESM CLI ([#356](https://github.com/wxt-dev/wxt/pull/356))
### 🏡 Chore
- Add unit tests for passing flags via the CLI ([#354](https://github.com/wxt-dev/wxt/pull/354))
## v0.14.3
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.2...v0.14.3)
### 🩹 Fixes
- Make `getArrayFromFlags` result can be undefined ([#352](https://github.com/wxt-dev/wxt/pull/352))
### ❤️ Contributors
- Yuns ([@yunsii](http://github.com/yunsii))
## v0.14.2
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.1...v0.14.2)
### 🚀 Enhancements
- Add `filterEntrypoints` option to speed up development ([#344](https://github.com/wxt-dev/wxt/pull/344))
### 🔥 Performance
- Only call `findEntrypoint` once per build ([#342](https://github.com/wxt-dev/wxt/pull/342))
### 🩹 Fixes
- Improve error message and document use of imported variables outside an entrypoint's `main` function ([#346](https://github.com/wxt-dev/wxt/pull/346))
- Allow `browser.runtime.getURL` to include hashes and query params for HTML paths ([#350](https://github.com/wxt-dev/wxt/pull/350))
### 📖 Documentation
- Fix typos and outdated ui function usage ([#347](https://github.com/wxt-dev/wxt/pull/347))
### 🏡 Chore
- Update templates to `^0.14.0` ([70a4961](https://github.com/wxt-dev/wxt/commit/70a4961))
- Fix typo in function name ([a329e24](https://github.com/wxt-dev/wxt/commit/a329e24))
### ❤️ Contributors
- Yuns ([@yunsii](http://github.com/yunsii))
- Armin
## v0.14.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.0...v0.14.1)
+9 -1
View File
@@ -57,9 +57,17 @@ pnpm test
pnpm docs:dev
```
## Updating Docs
Documentation is written with VitePress, and is located in the `docs/` directory.
The API reference is generated from JSDoc comments in the source code. If there's a typo or change you want to make in there, you'll need to update the source code instead of a file in the `docs/` directory.
## Testing
WXT has unit and E2E tests. When making a change or adding a feature, make sure to update the tests or add new ones.
WXT has unit and E2E tests. When making a change or adding a feature, make sure to update the tests or add new ones, if they exist.
> If they don't exist, feel free to create them, but that's a lot for a one-time contributor. A maintainer might add them to your PR though.
To run tests for a specific file, add the filename at the end of the test command:
+18 -24
View File
@@ -1,37 +1,31 @@
import messages from 'public/_locales/en/messages.json';
export default defineBackground(() => {
console.log(browser.runtime.id);
logId();
console.log({
browser: __BROWSER__,
chrome: __IS_CHROME__,
firefox: __IS_FIREFOX__,
manifestVersion: __MANIFEST_VERSION__,
browser: import.meta.env.BROWSER,
chrome: import.meta.env.CHROME,
firefox: import.meta.env.FIREFOX,
manifestVersion: import.meta.env.MANIFEST_VERSION,
messages,
});
// @ts-expect-error: should only accept entrypoints or public assets
browser.runtime.getURL('/');
browser.runtime.getURL('/background.js');
browser.runtime.getURL('/icon-128.png');
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');
console.log([
// @ts-expect-error: browser.i18n should only accept known message names
browser.i18n.getMessage('test'),
browser.i18n.getMessage('promptForName'),
browser.i18n.getMessage('hello', ['Aaron']),
browser.i18n.getMessage('bye', ['Aaron']),
browser.i18n.getMessage('@@extension_id'),
browser.i18n.getMessage('nItems'),
]);
console.log([
// @ts-expect-error: i18n should only accept known message names
i18n.t('test'),
i18n.t('promptForName'),
i18n.t('hello', ['Aaron']),
i18n.t('bye', ['Aaron']),
i18n.t('@@extension_id'),
i18n.tp('nItems', 0, ['0']),
]);
// @ts-expect-error: should only accept known message names
browser.i18n.getMessage('test');
browser.i18n.getMessage('prompt_for_name');
browser.i18n.getMessage('hello', 'Aaron');
browser.i18n.getMessage('bye', ['Aaron']);
browser.i18n.getMessage('@@extension_id');
console.log('WXT MODE:', {
MODE: import.meta.env.MODE,
-21
View File
@@ -1,21 +0,0 @@
promptForName: What's your name?
hello:
message: Hello, $USER$
description: Greet the user
placeholders:
user:
content: $1
example: Paul
bye:
message: Goodbye, $USER$. Come back to $OUR_SITE$ soon!
description: Say goodbye to the user
placeholders:
our_site:
content: Example.com
user:
content: $1
example: Paul
nItems:
0: 0 items
1: 1 item
n: $1 items
+29
View File
@@ -0,0 +1,29 @@
{
"prompt_for_name": {
"message": "What's your name?",
"description": "Ask for the user's name"
},
"hello": {
"message": "Hello, $USER$",
"description": "Greet the user",
"placeholders": {
"user": {
"content": "$1",
"example": "Cira"
}
}
},
"bye": {
"message": "Goodbye, $USER$. Come back to $OUR_SITE$ soon!",
"description": "Say goodbye to the user",
"placeholders": {
"our_site": {
"content": "Example.com"
},
"user": {
"content": "$1",
"example": "Cira"
}
}
}
}

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 504 B

Before

Width:  |  Height:  |  Size: 936 B

After

Width:  |  Height:  |  Size: 936 B

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

-1
View File
@@ -89,7 +89,6 @@ export default defineConfig({
{ text: 'Storage', link: '/guide/storage.md' },
{ text: 'Assets', link: '/guide/assets.md' },
{ text: 'Content Script UI', link: '/guide/content-script-ui.md' },
{ text: 'Localization', link: '/guide/localization.md' },
{ text: 'Multiple Browsers', link: '/guide/multiple-browsers.md' },
{ text: 'Auto-imports', link: '/guide/auto-imports.md' },
{ text: 'Vite', link: '/guide/vite.md' },
+31 -12
View File
@@ -21,6 +21,8 @@ Integrated content script UIs are injected alongside the content of a page. This
```ts [Vanilla]
// entrypoints/example-ui.content.ts
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
@@ -45,6 +47,8 @@ import { createApp } from 'vue';
import App from './App.vue';
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
@@ -73,6 +77,8 @@ import ReactDOM from 'react-dom/client';
import App from './App.tsx';
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
@@ -95,11 +101,13 @@ export default defineContentScript({
});
```
```ts [Svelete]
```ts [Svelte]
// entrypoints/example-ui.content/index.ts
import App from './App.svelte';
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
@@ -128,6 +136,8 @@ export default defineContentScript({
import { render } from 'solid-js/web';
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
@@ -174,6 +184,7 @@ To use `createShadowRootUi`, follow these steps:
import './style.css';
export default defineContentScript({
matches: ['<all_urls>'],
// 2. Set cssInjectionMode
cssInjectionMode: 'ui',
@@ -182,7 +193,7 @@ export default defineContentScript({
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
anchor: '#anchor',
type: 'inline',
position: 'inline',
onMount(container) {
// Define how your UI will be mounted inside the container
const app = document.createElement('p');
@@ -204,6 +215,7 @@ import { createApp } from 'vue';
import App from './App.vue';
export default defineContentScript({
matches: ['<all_urls>'],
// 2. Set cssInjectionMode
cssInjectionMode: 'ui',
@@ -212,7 +224,7 @@ export default defineContentScript({
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
anchor: '#anchor',
type: 'inline',
position: 'inline',
onMount: (container) => {
// Define how your UI will be mounted inside the container
const app = createApp(App);
@@ -238,12 +250,14 @@ import ReactDOM from 'react-dom/client';
import App from './App.tsx';
export default defineContentScript({
matches: ['<all_urls>'],
// 2. Set cssInjectionMode
cssInjectionMode: 'ui',
main(ctx) {
async main(ctx) {
// 3. Define your UI
const ui = createIntegratedUi(ctx, {
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
@@ -264,18 +278,20 @@ export default defineContentScript({
});
```
```ts [Svelete]
```ts [Svelte]
// 1. Import the style
import './style.css';
import App from './App.svelte';
export default defineContentScript({
matches: ['<all_urls>'],
// 2. Set cssInjectionMode
cssInjectionMode: 'ui',
main(ctx) {
async main(ctx) {
// 3. Define your UI
const ui = createIntegratedUi(ctx, {
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
@@ -303,12 +319,14 @@ import './style.css';
import { render } from 'solid-js/web';
export default defineContentScript({
matches: ['<all_urls>'],
// 2. Set cssInjectionMode
cssInjectionMode: 'ui',
main(ctx) {
async main(ctx) {
// 3. Define your UI
const ui = createIntegratedUi(ctx, {
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
@@ -374,13 +392,14 @@ WXT provides a helper function, [`createIframeUi`](/api/wxt/client/functions/cre
```ts
export default defineContentScript({
// ...
matches: ['<all_urls>'],
async main(ctx) {
// Define the UI
const ui = await createIframeUi(ctx, {
page: '/example-iframe.html',
anchor: '#anchor',
type: 'inline',
position: 'inline',
onMount: (wrapper, iframe) => {
// Add styles to the iframe like width
iframe.width = 123;
+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() {
// ...
},
});
```
-159
View File
@@ -1,159 +0,0 @@
---
outline: deep
---
# Localization
WXT includes a util, [`i18n`](/api/wxt/i18n/), that provides a type-safe feature-rich alternative to `browser.i18n.getMessage`.
It is available automatically when you create a messages file under the `locales/` directory, and add a `default_locale` to the manifest:
```
<srcDir>
└─ locales/
├─ en.json
├─ es.json5
├─ fr.yml
├─ de.yaml
└─ ...
```
> You can use JSON, JSON5, or YAML formats.
```ts
// wxt.config.ts
export default defineConfig({
manifest: {
default_locale: 'en',
},
});
```
## Message File Format
```yml
# Use plain strings
simpleMessage: Hello world!
# Nest strings in objects
popup:
overview:
title: Nested text
# Plural form support
items:
1: 1 item
n: $1 items
# Optionally include a custom string for 0
cartSize:
0: Empty
1: 1 item
n: $1 items
# Or stick with the standard web extension format (with a message, description, and placeholders)
manifestMessage:
message: $THIS$ is translated
description: This is not-translated, helps translators
placeholder:
this:
content: This
```
:::tip
`locales/<code>.json` is 100% compatible with the standard web extension localization format (`_locales/<code>/messages.json`). If you have existing messages files, just move them into the `locales/` directory.
:::
## Usage
`i18n` is auto-imported, but can be manually imported from `wxt/i18n`.
```ts
import { i18n } from 'wxt/i18n';
```
### Basic Usage
You can access messages by their name:
```yml
helloWorld: Hello world!
```
```ts
i18n.t('helloWorld'); // "Hello world!"
```
Nested messages are combined into one string using an `_`.
```yml
popup:
overview:
title: Hello world!
```
```ts
i18n.t('popup_overview_title'); // "Hello world!"
```
If a message is in the standard web extension format, don't include a `_message`, even if it's nested.
```yml
helloWorld:
message: Hello world!
description: Some description
popup:
overview:
title:
message: Nested Title
```
```ts
i18n.t('helloWorld'); // "Hello world!"
i18n.t('popup_overview_title'); // "Nested Title"
```
### Substitutions
To insert a custom string into a translation, pass an array of values as the second parameter of the `i18n.t` function:
```yml
hello: Hello, $1, my name is $2.
```
```ts
i18n.t('hello', ['Aaron', 'Mark']); // "Hello Aaron, my name is Mark."
```
### Plural Form
When getting the translation for text with a plural form, use the `i18n.tp` function.
```yml
friends:
0: I have no friends.
1: I have a friend.
n: I have many friends.
```
```ts
i18n.tp('friends', 0); // "I have no friends."
i18n.tp('friends', 1); // "I have one friend."
i18n.tp('friends', 2); // "I have many friends."
```
The first number is the `count`. It is what decides which form will be used.
Substitutions are not required. But usually, a plural form will look something like this:
```yml
items:
1: 1 item
n: $1 items
```
```ts
i18n.tp('items', 0, ['0']); // "0 items"
i18n.tp('items', 1, ['1']); // "1 item"
i18n.tp('items', 2, ['2']); // "2 items"
i18n.tp('items', 3, ['3']); // "3 items"
```
+22 -3
View File
@@ -17,11 +17,9 @@ Separate build targets are written to their own output directories:
To build for a specific browser, pass the `-b --browser` flag from the CLI:
```
```sh
wxt --browser firefox
wxt build --browser firefox
```
By default, it will build for `chrome`. When excluding the [manifest version flags](#target-manifest-version), it will default to the commonly accepted manifest version for that browser.
@@ -112,3 +110,24 @@ export default defineContentScript({
:::warning
Only `defineBackground` and `defineContentScript` support per-browser options right now.
:::
## Runtime
To determine the browser or manifest version at runtime, you can use any of the below variables:
- `import.meta.env.BROWSER`: A string, the target browser, usually equal to the `--browser` flag
- `import.meta.env.MANIFEST_VERSION`: A number, either `2` or `3`, depending on the manifest version targetted
- `import.meta.env.CHROME`: A boolean equivalent to `import.meta.env.BROWSER === "chrome"`
- `import.meta.env.FIREFOX`: A boolean equivalent to `import.meta.env.BROWSER === "firefox"`
- `import.meta.env.EDGE`: A boolean equivalent to `import.meta.env.BROWSER === "edge"`
- `import.meta.env.SAFARI`: A boolean equivalent to `import.meta.env.BROWSER === "safari"`
- `import.meta.env.OPERA`: A boolean equivalent to `import.meta.env.BROWSER === "opera"`
- `import.meta.env.COMMAND`: A string, `"serve"` when running `wxt` for development or `"build"` in all other cases.
:::info
These variables are constants defined at build time based on the build target. They do not actually detect which browser the code is running in.
For example, if you build for `--browser chrome` and publish it on Edge, `import.meta.env.BROWSER` will be `"chrome"`, not `"edge"`. You have to build a separate ZIP for `--browser edge` before `import.meta.env.BROWSER` will be `"edge"`.
If you need to know the actual browser your code is being ran on, you should use a [user agent parser](https://www.npmjs.com/package/ua-parser-js).
:::
+1 -1
View File
@@ -19,7 +19,7 @@ If you want to try to use a different framework for unit tests, you will need to
- **Auto-imports**: Add `unimport` to your test environment or disable them by setting `imports: false` in your `wxt.config.ts` file
- **`browser` mock**: Mock the `webextension-polyfill` module globally with `wxt/dist/virtual/mock-browser.js`
- **[Remote Code Bundling](/guide/remote-code)**: If you use it, configure your environment to handle the `url:` module prefix
- **Global Variables**: If you consume them, manually define globals provided by WXT (like `__BROWSER__`) by adding them to the global scope before accessing them (`globalThis.__BROWSER__ = "chrome"`)
- **Global Variables**: If you consume them, manually define globals provided by WXT (like `import.meta.env.BROWSER`) by adding them to the global scope before accessing them (`import.meta.env.BROWSER = "chrome"`)
- **Import paths**: If you use the `@/` or `~/` path aliases, add them to your test environment
[Here's how Vitest is configured](https://github.com/wxt-dev/wxt/blob/main/src/testing/wxt-vitest-plugin.ts) for reference.
+1 -2
View File
@@ -5,8 +5,7 @@
"../src/browser.ts",
"../src/sandbox",
"../src/storage.ts",
"../src/testing",
"../src/i18n"
"../src/testing"
],
"plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme"],
"out": "./api",
-1
View File
@@ -28,7 +28,6 @@ describe('Auto Imports', () => {
const defineContentScript: typeof import('wxt/sandbox')['defineContentScript']
const defineUnlistedScript: typeof import('wxt/sandbox')['defineUnlistedScript']
const fakeBrowser: typeof import('wxt/testing')['fakeBrowser']
const i18n: typeof import('wxt/i18n')['i18n']
const storage: typeof import('wxt/storage')['storage']
}
"
+46 -60
View File
@@ -14,16 +14,19 @@ describe('TypeScript Project', () => {
----------------------------------------
// Generated by wxt
export {}
declare global {
const __MANIFEST_VERSION__: 2 | 3;
const __BROWSER__: string;
const __IS_CHROME__: boolean;
const __IS_FIREFOX__: boolean;
const __IS_SAFARI__: boolean;
const __IS_EDGE__: boolean;
const __IS_OPERA__: boolean;
const __COMMAND__: "build" | "serve";
const __ENTRYPOINT__: string;
interface ImportMetaEnv {
readonly MANIFEST_VERSION: 2 | 3;
readonly BROWSER: string;
readonly CHROME: boolean;
readonly FIREFOX: boolean;
readonly SAFARI: boolean;
readonly EDGE: boolean;
readonly OPERA: boolean;
readonly COMMAND: "build" | "serve";
readonly ENTRYPOINT: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
"
`);
@@ -49,8 +52,10 @@ describe('TypeScript Project', () => {
| "/options.html"
| "/popup.html"
| "/sandbox.html"
type HtmlPublicPath = Extract<PublicPath, \`\${string}.html\`>
export interface WxtRuntime extends Runtime.Static {
getURL(path: PublicPath): string;
getURL(path: \`\${HtmlPublicPath}\${string}\`): string;
}
}
"
@@ -61,7 +66,7 @@ describe('TypeScript Project', () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
project.addFile(
'locales/en.json',
'public/_locales/en/messages.json',
JSON.stringify({
prompt_for_name: {
message: "What's your name?",
@@ -119,36 +124,6 @@ describe('TypeScript Project', () => {
}
export interface WxtI18n extends I18n.Static {
/**
* Ask for the user's name
*
* "What's your name?"
*/
getMessage(
messageName: "prompt_for_name",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* Greet the user
*
* "Hello, $USER$"
*/
getMessage(
messageName: "hello",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* Say goodbye to the user
*
* "Goodbye, $USER$. Come back to $OUR_SITE$ soon!"
*/
getMessage(
messageName: "bye",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* 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.
@@ -210,25 +185,36 @@ describe('TypeScript Project', () => {
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
}
}
declare module "wxt/i18n" {
export interface WxtMessageSchema {
t: {
"prompt_for_name": any;
"hello": any;
"bye": any;
"@@extension_id": any;
"@@ui_locale": any;
"@@bidi_dir": any;
"@@bidi_reversed_dir": any;
"@@bidi_start_edge": any;
"@@bidi_end_edge": any;
};
tp: {
};
/**
* Ask for the user's name
*
* "What's your name?"
*/
getMessage(
messageName: "prompt_for_name",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* Greet the user
*
* "Hello, $USER$"
*/
getMessage(
messageName: "hello",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* Say goodbye to the user
*
* "Goodbye, $USER$. Come back to $OUR_SITE$ soon!"
*/
getMessage(
messageName: "bye",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
}
}
"
+5 -13
View File
@@ -1,7 +1,7 @@
{
"name": "wxt",
"type": "module",
"version": "0.14.2-alpha2",
"version": "0.15.0",
"description": "Next gen framework for developing web extensions",
"engines": {
"node": ">=18",
@@ -77,16 +77,6 @@
"default": "./dist/storage.cjs"
}
},
"./i18n": {
"import": {
"types": "./dist/i18n.d.ts",
"default": "./dist/i18n.js"
},
"require": {
"types": "./dist/i18n.d.cts",
"default": "./dist/i18n.cjs"
}
},
"./vite-builder-env": {
"types": "./dist/vite-builder-env.d.ts"
}
@@ -135,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",
@@ -143,15 +135,15 @@
"rollup-plugin-visualizer": "^5.9.2",
"unimport": "^3.4.0",
"vite": "^5.0.0",
"web-ext-run": "^0.1.0",
"web-ext-run": "^0.1.2",
"webextension-polyfill": "^0.10.0",
"yaml": "^2.3.4",
"zip-dir": "^2.0.0"
},
"devDependencies": {
"@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",
+55 -27
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
@@ -99,14 +105,11 @@ importers:
specifier: ^5.0.0
version: 5.0.5(@types/node@20.10.3)(sass@1.69.5)
web-ext-run:
specifier: ^0.1.0
version: 0.1.0
specifier: ^0.1.2
version: 0.1.2
webextension-polyfill:
specifier: ^0.10.0
version: 0.10.0
yaml:
specifier: ^2.3.4
version: 2.3.4
zip-dir:
specifier: ^2.0.0
version: 2.0.0
@@ -120,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
@@ -406,8 +412,8 @@ packages:
'@babel/types': 7.23.5
dev: true
/@babel/runtime@7.22.11:
resolution: {integrity: sha512-ee7jVNlWN09+KftVOu9n7S8gQzD/Z6hN/I8VBRXW4P1+Xe7kJGXMwu8vds4aGIMHZnNbdpSWCfZZtinytpcAvA==}
/@babel/runtime@7.23.9:
resolution: {integrity: sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==}
engines: {node: '>=6.9.0'}
dependencies:
regenerator-runtime: 0.14.0
@@ -436,8 +442,8 @@ packages:
engines: {node: '>= 0.10.4'}
dev: false
/@devicefarmer/adbkit@3.2.5:
resolution: {integrity: sha512-+J479WWZW3GU3t40flicDfiDrFz6vpiy2RcBQPEhFcs/3La9pOtr4Bgz2Q02E4luUG2RAL068rqIkKNUTy3tZw==}
/@devicefarmer/adbkit@3.2.6:
resolution: {integrity: sha512-8lO1hSeTgtxcOHhp4tTWq/JaOysp5KNbbyFoxNEBnwkCDZu/Bji3ZfOaG++Riv9jN6c9bgdLBOZqJTC5VJPRKQ==}
engines: {node: '>= 0.10.4'}
hasBin: true
dependencies:
@@ -1109,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:
@@ -2370,8 +2380,8 @@ packages:
dependencies:
to-regex-range: 5.0.1
/firefox-profile@4.3.2:
resolution: {integrity: sha512-/C+Eqa0YgIsQT2p66p7Ygzqe7NlE/GNTbhw2SBCm5V3OsWDPITNdTPEcH2Q2fe7eMpYYNPKdUcuVioZBZiR6kA==}
/firefox-profile@4.5.0:
resolution: {integrity: sha512-goE2XxbmYVSafvCjcy64/AK3xOr14HCUCD4+TpYWEIMy4nrJfNAacLGzwqKwZhCW0hHI2TYMGH+G/YBvOE8L6g==}
hasBin: true
dependencies:
adm-zip: 0.5.10
@@ -2412,6 +2422,15 @@ packages:
universalify: 2.0.0
dev: false
/fs-extra@11.2.0:
resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==}
engines: {node: '>=14.14'}
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.1.0
universalify: 2.0.0
dev: false
/fs-extra@9.0.1:
resolution: {integrity: sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ==}
engines: {node: '>=10'}
@@ -2457,8 +2476,8 @@ packages:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
dev: true
/fx-runner@1.3.0:
resolution: {integrity: sha512-5b37H4GCyhF+Nf8xk9mylXoDq4wb7pbGAXxlCXp/631UTeeZomWSYcEGXumY4wk8g2QAqjPMGdWW+RbNt8PUcA==}
/fx-runner@1.4.0:
resolution: {integrity: sha512-rci1g6U0rdTg6bAaBboP7XdRu01dzTAaKXxFf+PUqGuCv6Xu7o8NZdY1D5MvKGIjb6EdS1g3VlXOgksir1uGkg==}
hasBin: true
dependencies:
commander: 2.9.0
@@ -3355,6 +3374,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
@@ -3536,6 +3559,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
@@ -3754,8 +3781,8 @@ packages:
json-parse-better-errors: 1.0.2
dev: true
/parse-json@7.1.0:
resolution: {integrity: sha512-ihtdrgbqdONYD156Ap6qTcaGcGdkdAxodO1wLqQ/j7HP1u2sFYppINiq4jyC8F+Nm+4fVufylCV00QmkTHkSUg==}
/parse-json@7.1.1:
resolution: {integrity: sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw==}
engines: {node: '>=16'}
dependencies:
'@babel/code-frame': 7.22.5
@@ -5168,24 +5195,24 @@ packages:
graceful-fs: 4.2.11
dev: false
/web-ext-run@0.1.0:
resolution: {integrity: sha512-42ezvTBirf1v2hw0q4FqYvAzh39bCn3cMQojTLlaE5Y8DWebVkRGdd0jdSJRzPkB5jZa0CHfnqe8gx6oUllqyw==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
/web-ext-run@0.1.2:
resolution: {integrity: sha512-VygO7lg10keonbku5Lbi21zEz1k91odkW+c6YB5DGb+mITcRMVM2zr/tJ+Suh6wxNUOOFNbinoqDlSUWdxt6YQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
dependencies:
'@babel/runtime': 7.22.11
'@devicefarmer/adbkit': 3.2.5
'@babel/runtime': 7.23.9
'@devicefarmer/adbkit': 3.2.6
bunyan: 1.8.15
chrome-launcher: 0.15.1
debounce: 1.2.1
es6-error: 4.1.1
firefox-profile: 4.3.2
fs-extra: 11.1.1
fx-runner: 1.3.0
firefox-profile: 4.5.0
fs-extra: 11.2.0
fx-runner: 1.4.0
mkdirp: 1.0.4
multimatch: 6.0.0
mz: 2.7.0
node-notifier: 10.0.1
parse-json: 7.1.0
parse-json: 7.1.1
promise-toolbox: 0.21.0
source-map-support: 0.5.21
strip-bom: 5.0.0
@@ -5193,7 +5220,7 @@ packages:
tmp: 0.2.1
update-notifier: 6.0.2
watchpack: 2.4.0
ws: 8.13.0
ws: 8.16.0
zip-dir: 2.0.0
transitivePeerDependencies:
- bufferutil
@@ -5346,8 +5373,8 @@ packages:
typedarray-to-buffer: 3.1.5
dev: false
/ws@8.13.0:
resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==}
/ws@8.16.0:
resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -5388,6 +5415,7 @@ packages:
/yaml@2.3.4:
resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==}
engines: {node: '>= 14'}
dev: true
/yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
+5 -1
View File
@@ -58,7 +58,6 @@ const config: tsup.Options[] = [
index: 'src/index.ts',
testing: 'src/testing/index.ts',
storage: 'src/storage.ts',
i18n: 'src/i18n/index.ts',
},
format: ['cjs', 'esm'],
clean: true,
@@ -98,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;
}
+5 -3
View File
@@ -15,10 +15,12 @@ export class WxtLocationChangeEvent extends Event {
* Returns an event name unique to the extension and content script that's running.
*/
export function getUniqueEventName(eventName: string): string {
// During the build process, __ENTRYPOINT__ is not defined when importing entrypoints to get their
// metadata.
// During the build process, import.meta.env is not defined when importing
// entrypoints to get their metadata.
const entrypointName =
typeof __ENTRYPOINT__ === 'undefined' ? 'build' : __ENTRYPOINT__;
typeof import.meta.env === 'undefined'
? 'build'
: import.meta.env.ENTRYPOINT;
return `${browser.runtime.id}:${entrypointName}:${eventName}`;
}
+3 -1
View File
@@ -236,7 +236,9 @@ function mountUi(
* Load the CSS for the current entrypoint.
*/
async function loadCss(): Promise<string> {
const url = browser.runtime.getURL(`/content-scripts/${__ENTRYPOINT__}.css`);
const url = browser.runtime.getURL(
`/content-scripts/${import.meta.env.ENTRYPOINT}.css`,
);
try {
const res = await fetch(url);
return await res.text();
+7
View File
@@ -44,6 +44,10 @@ export async function createViteBuilder(
if (config.build.minify == null && wxtConfig.command === 'serve') {
config.build.minify = false;
}
// Enable inline sourcemaps for the dev command (so content scripts have sourcemaps)
if (config.build.sourcemap == null && wxtConfig.command === 'serve') {
config.build.sourcemap = 'inline';
}
config.plugins ??= [];
config.plugins.push(
@@ -230,6 +234,9 @@ export async function createViteBuilder(
async listen() {
await viteServer.listen(info.port);
},
async close() {
await viteServer.close();
},
transformHtml(...args) {
return viteServer.transformIndexHtml(...args);
},
@@ -14,7 +14,7 @@ export function entrypointGroupGlobals(
const define: vite.InlineConfig['define'] = {};
let name = Array.isArray(entrypointGroup) ? 'html' : entrypointGroup.name;
for (const global of getEntrypointGlobals(name)) {
define[global.name] = JSON.stringify(global.value);
define[`import.meta.env.${global.name}`] = JSON.stringify(global.value);
}
return {
define,
+1 -1
View File
@@ -10,7 +10,7 @@ export function globals(
config() {
const define: vite.InlineConfig['define'] = {};
for (const global of getGlobals(config)) {
define[global.name] = JSON.stringify(global.value);
define[`import.meta.env.${global.name}`] = JSON.stringify(global.value);
}
return {
define,
+5 -1
View File
@@ -36,7 +36,11 @@ export function unimport(
// Don't transform non-js files
if (!ENABLED_EXTENSIONS.has(extname(id))) return;
return unimport.injectImports(code, id);
const injected = await unimport.injectImports(code, id);
return {
code: injected.code,
map: injected.s.generateMap({ hires: 'boundary', source: id }),
};
},
};
}
+65 -16
View File
@@ -49,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);
@@ -78,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);
});
@@ -138,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) => {
@@ -197,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);
+184 -37
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,6 +965,7 @@ describe('Manifest Utils', () => {
describe('commands', () => {
const reloadCommandName = 'wxt:reload-extension';
const reloadCommand = {
description: expect.any(String),
suggested_key: {
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]`,
);
});
});
+11 -44
View File
@@ -6,11 +6,9 @@ import {
} from '~/types';
import { getPublicFiles } from '~/core/utils/fs';
import fs from 'fs-extra';
import { dirname, resolve, extname } from 'node:path';
import { dirname, resolve } from 'path';
import type { Ora } from 'ora';
import pc from 'picocolors';
import { unnormalizePath } from '~/core/utils/paths';
import { convertMessagesToManifest, readMessagesFile } from '~/i18n/node';
export async function buildEntrypoints(
groups: EntrypointGroup[],
@@ -20,20 +18,17 @@ export async function buildEntrypoints(
const steps: BuildStepOutput[] = [];
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
const groupNames = [group]
.flat()
.map((e) => e.name)
.join(pc.dim(', '));
spinner.text = pc.dim(`[${i + 1}/${groups.length}]`) + ` ${groupNames}`;
steps.push(await config.builder.build(group));
const groupNames = [group].flat().map((e) => e.name);
const groupNameColored = groupNames.join(pc.dim(', '));
spinner.text =
pc.dim(`[${i + 1}/${groups.length}]`) + ` ${groupNameColored}`;
try {
steps.push(await config.builder.build(group));
} catch (err) {
throw Error(`Failed to build ${groupNames.join(', ')}`, { cause: err });
}
}
const publicAssets = (
await Promise.all([
copyPublicDirectory(config),
copyLocalesDirectory(config),
])
).flat();
const publicAssets = await copyPublicDirectory(config);
return { publicAssets, steps };
}
@@ -59,31 +54,3 @@ async function copyPublicDirectory(
return publicAssets;
}
async function copyLocalesDirectory(
config: InternalConfig,
): Promise<BuildOutput['publicAssets']> {
const localesExist = await fs.exists(config.localesDir);
if (!localesExist || config.manifest.default_locale == null) return [];
const files = await fs.readdir(config.localesDir);
return await Promise.all(
files.map(async (file) => {
const locale = file.replace(extname(file), '');
const fileName = unnormalizePath(`_locales/${locale}/messages.json`);
const srcPath = resolve(config.localesDir, file);
const outPath = resolve(config.outDir, fileName);
const messages = await readMessagesFile(srcPath);
const json = convertMessagesToManifest(messages);
await fs.ensureDir(dirname(outPath));
await fs.writeJson(outPath, json);
return {
fileName,
type: 'asset',
};
}),
);
}
+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,
};
}
+22 -40
View File
@@ -6,9 +6,9 @@ import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
import { getUnimportOptions } from '~/core/utils/unimport';
import { getEntrypointGlobals, getGlobals } from '~/core/utils/globals';
import { normalizePath } from '~/core/utils/paths';
import path from 'node:path';
import { Message, parseI18nMessages } from '~/core/utils/i18n';
import { writeFileIfDifferent, getPublicFiles } from '~/core/utils/fs';
import glob from 'fast-glob';
import { Message, PREDEFINED_MESSAGES, readMessagesFile } from '~/i18n/node';
/**
* Generate and write all the files inside the `InternalConfig.typesDir` directory.
@@ -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;
}
}
`;
@@ -113,38 +115,30 @@ declare module "wxt/browser" {
}
export interface WxtI18n extends I18n.Static {
{{ browserOverrides }}
}
}
declare module "wxt/i18n" {
export interface WxtMessageSchema {
t: {
{{ translationTOverrides }}
};
tp: {
{{ translationTpOverrides }}
};
{{ overrides }}
}
}
`;
let messages: Message[];
if (defaultLocale) {
const [defaultLocalePath] = await glob(`${defaultLocale}.*`, {
cwd: config.localesDir,
absolute: true,
});
messages = await readMessagesFile(defaultLocalePath);
const defaultLocalePath = path.resolve(
config.publicDir,
'_locales',
defaultLocale,
'messages.json',
);
const content = JSON.parse(await fs.readFile(defaultLocalePath, 'utf-8'));
messages = parseI18nMessages(content);
} else {
messages = PREDEFINED_MESSAGES;
messages = parseI18nMessages({});
}
const overrides = messages.map((message) => {
return ` /**
* ${message.entry.description ?? 'No message description.'}
* ${message.description || 'No message description.'}
*
* "${message.entry.message}"
* "${message.message}"
*/
getMessage(
messageName: "${message.name}",
@@ -154,22 +148,7 @@ declare module "wxt/i18n" {
});
await writeFileIfDifferent(
filePath,
template
.replace('{{ browserOverrides }}', overrides.join('\n'))
.replace(
'{{ translationTOverrides }}',
messages
.filter((message) => !message.isPlural)
.map((message) => ` "${message.name}": any;`)
.join('\n'),
)
.replace(
'{{ translationTpOverrides }}',
messages
.filter((message) => message.isPlural)
.map((message) => ` "${message.name}": any;`)
.join('\n'),
),
template.replace('{{ overrides }}', overrides.join('\n')),
);
return filePath;
@@ -185,8 +164,11 @@ async function writeGlobalsDeclarationFile(
[
'// Generated by wxt',
'export {}',
'declare global {',
...globals.map((global) => ` const ${global.name}: ${global.type};`),
'interface ImportMetaEnv {',
...globals.map((global) => ` readonly ${global.name}: ${global.type};`),
'}',
'interface ImportMeta {',
' readonly env: ImportMetaEnv',
'}',
].join('\n') + '\n',
);
+17 -5
View File
@@ -72,11 +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 localesDir = path.resolve(srcDir, mergedConfig.localesDir ?? 'locales');
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',
@@ -102,6 +105,7 @@ export async function getInternalConfig(
command,
debug,
entrypointsDir,
filterEntrypoints,
env,
fsCache: createFsCache(wxtDir),
imports: mergedConfig.imports ?? {},
@@ -112,7 +116,6 @@ export async function getInternalConfig(
outBaseDir,
outDir,
publicDir,
localesDir,
root,
runnerConfig,
srcDir,
@@ -134,6 +137,9 @@ export async function getInternalConfig(
mergedConfig.experimental?.includeBrowserPolyfill ?? true,
},
server,
dev: {
reloadCommand,
},
};
const builder = await createViteBuilder(
@@ -193,12 +199,13 @@ 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,
mode: inlineConfig.mode ?? userConfig.mode,
publicDir: inlineConfig.publicDir ?? userConfig.publicDir,
localesDir: inlineConfig.localesDir ?? userConfig.localesDir,
runner,
srcDir: inlineConfig.srcDir ?? userConfig.srcDir,
outDir: inlineConfig.outDir ?? userConfig.outDir,
@@ -218,6 +225,10 @@ function mergeInlineConfig(
},
vite: undefined,
transformManifest: undefined,
dev: {
...userConfig.dev,
...inlineConfig.dev,
},
};
}
@@ -230,8 +241,9 @@ function resolveInternalZipConfig(
sourcesTemplate: '{{name}}-{{version}}-sources.zip',
artifactTemplate: '{{name}}-{{version}}-{{browser}}.zip',
sourcesRoot: root,
includeSources: [],
...mergedConfig.zip,
ignoredSources: [
excludeSources: [
'**/node_modules',
// WXT files
'**/web-ext.config.ts',
@@ -241,7 +253,7 @@ function resolveInternalZipConfig(
'**/__tests__/**',
'**/*.+(test|spec).?(c|m)+(j|t)s?(x)',
// From user
...(mergedConfig.zip?.ignoredSources ?? []),
...(mergedConfig.zip?.excludeSources ?? []),
],
};
}
+13 -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,18 @@ export async function importEntrypointFile<T>(
const res = await jiti(path);
return res.default;
} catch (err) {
config.logger.error(err);
throw err;
const filePath = relative(config.root, path);
if (err instanceof ReferenceError) {
// "XXX is not defined" - usually due to WXT removing imports
const variableName = err.message.replace(' is not defined', '');
throw Error(
`${filePath}: Cannot use imported variable "${variableName}" outside the main function. See https://wxt.dev/guide/entrypoints.html#side-effects`,
{ cause: err },
);
} else {
config.logger.error(err);
throw Error(`Failed to load entrypoint: ${filePath}`, { cause: err });
}
}
}
+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, entrypoints, 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();
});
}
+8 -6
View File
@@ -33,7 +33,11 @@ export async function rebuild(
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();
@@ -51,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,
@@ -77,5 +78,6 @@ export async function rebuild(
],
},
manifest: newManifest,
warnings: manifestWarnings,
};
}
+9 -17
View File
@@ -5,42 +5,42 @@ export function getGlobals(
): Array<{ name: string; value: any; type: string }> {
return [
{
name: surroundInUnderscore('MANIFEST_VERSION'),
name: 'MANIFEST_VERSION',
value: config.manifestVersion,
type: `2 | 3`,
},
{
name: surroundInUnderscore('BROWSER'),
name: 'BROWSER',
value: config.browser,
type: `string`,
},
{
name: surroundInUnderscore('IS_CHROME'),
name: 'CHROME',
value: config.browser === 'chrome',
type: `boolean`,
},
{
name: surroundInUnderscore('IS_FIREFOX'),
name: 'FIREFOX',
value: config.browser === 'firefox',
type: `boolean`,
},
{
name: surroundInUnderscore('IS_SAFARI'),
name: 'SAFARI',
value: config.browser === 'safari',
type: `boolean`,
},
{
name: surroundInUnderscore('IS_EDGE'),
name: 'EDGE',
value: config.browser === 'edge',
type: `boolean`,
},
{
name: surroundInUnderscore('IS_OPERA'),
name: 'OPERA',
value: config.browser === 'opera',
type: `boolean`,
},
{
name: surroundInUnderscore('COMMAND'),
name: 'COMMAND',
value: config.command,
type: `"build" | "serve"`,
},
@@ -50,17 +50,9 @@ export function getGlobals(
export function getEntrypointGlobals(entrypointName: string) {
return [
{
name: surroundInUnderscore('ENTRYPOINT'),
name: 'ENTRYPOINT',
value: entrypointName,
type: `string`,
},
];
}
/**
* Don't hardcode the complete name so that the string litterals in this file aren't replaced during
* tests (which causes syntax errors), only during builds.
*/
function surroundInUnderscore(name: string): string {
return `__${name}__`;
}
+52
View File
@@ -0,0 +1,52 @@
export interface Message {
name: string;
message: string;
description?: string;
}
const predefinedMessages = {
'@@extension_id': {
message: '<browser.runtime.id>',
description:
"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.\nNote: You can't use this message in a manifest file.",
},
'@@ui_locale': {
message: '<browser.i18n.getUiLocale()>',
description: '',
},
'@@bidi_dir': {
message: '<ltr|rtl>',
description:
'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.',
},
'@@bidi_reversed_dir': {
message: '<rtl|ltr>',
description:
'If the @@bidi_dir is "ltr", then this is "rtl"; otherwise, it\'s "ltr".',
},
'@@bidi_start_edge': {
message: '<left|right>',
description:
'If the @@bidi_dir is "ltr", then this is "left"; otherwise, it\'s "right".',
},
'@@bidi_end_edge': {
message: '<right|left>',
description:
'If the @@bidi_dir is "ltr", then this is "right"; otherwise, it\'s "left".',
},
};
/**
* Get a list of all messages and their metadata from JSON file contents.
*
* @param messagesJson The contents of a `_locales/en/messages.json` file.
*/
export function parseI18nMessages(messagesJson: object): Message[] {
return Object.entries({
...predefinedMessages,
...messagesJson,
}).map<Message>(([name, details]) => ({
name,
...details,
}));
}
+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: 'Alt+R',
},
},
};
}
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,
};
}
/**
+25 -8
View File
@@ -4,7 +4,7 @@
import { resolve } from 'path';
import { faker } from '@faker-js/faker';
import merge from 'lodash.merge';
import type { Manifest } from '~/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(),
}),
);
@@ -211,7 +217,6 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
outBaseDir: fakeDir(),
outDir: fakeDir(),
publicDir: fakeDir(),
localesDir: fakeDir(),
root: fakeDir(),
runnerConfig: {
config: {},
@@ -227,7 +232,8 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
},
zip: {
artifactTemplate: '{{name}}-{{version}}.zip',
ignoredSources: [],
includeSources: [],
excludeSources: [],
sourcesRoot: fakeDir(),
sourcesTemplate: '{{name}}-sources.zip',
name: faker.person.firstName().toLowerCase(),
@@ -239,6 +245,9 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
includeBrowserPolyfill: true,
},
builder: mock(),
dev: {
reloadCommand: 'Alt+R',
},
};
});
@@ -252,3 +261,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,
})}`,
}));
-1
View File
@@ -12,7 +12,6 @@ export function getUnimportOptions(
imports: [
{ name: 'defineConfig', from: 'wxt' },
{ name: 'fakeBrowser', from: 'wxt/testing' },
{ name: 'i18n', from: 'wxt/i18n' },
],
presets: [
{ package: 'wxt/client' },
+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 {}
+8 -3
View File
@@ -62,10 +62,15 @@ export async function zip(config?: InlineConfig): Promise<string[]> {
saveTo: sourcesZipPath,
filter(path) {
const relativePath = relative(internalConfig.zip.sourcesRoot, path);
const matchedPattern = internalConfig.zip.ignoredSources.find(
(pattern) => minimatch(relativePath, pattern),
return (
internalConfig.zip.includeSources.some((pattern) =>
minimatch(relativePath, pattern),
) ||
!internalConfig.zip.excludeSources.some((pattern) =>
minimatch(relativePath, pattern),
)
);
return matchedPattern == null;
},
});
zipFiles.push(sourcesZipPath);
-165
View File
@@ -1,165 +0,0 @@
import { describe, it, vi, expect } from 'vitest';
import { createExtensionI18n } from '../client';
import { fakeBrowser } from '@webext-core/fake-browser';
let getMessageMock = vi.fn();
fakeBrowser.i18n.getMessage = getMessageMock;
describe('createExtensionI18n', () => {
describe('t', () => {
it('should return the text from browsesr.i18n.getMessage', () => {
const expected = 'Hello world';
const i18n = createExtensionI18n();
getMessageMock.mockReturnValue(expected);
const actual = i18n.t('key', ['']);
expect(actual).toBe(expected);
});
it("should return a blank string when the message doesn't exist", () => {
const i18n = createExtensionI18n();
// browser.i18n.getMessage returns a blank string when the message doesn't exist
getMessageMock.mockReturnValue('');
const actual = i18n.t('unknown-key');
expect(actual).toBe('');
});
it('should pass substitutions in correctly', () => {
const i18n = createExtensionI18n();
const key = 'key';
const subs = ['1', 'two'];
i18n.t(key, subs);
expect(getMessageMock).toBeCalledWith(key, subs);
});
});
describe('tp', () => {
it.each([
[0, '0 items'],
[1, '1 items'],
[2, '2 items'],
[3, '3 items'],
])(
'should return the correct string for format "{n}" and count=%s',
(count, expected) => {
const i18n = createExtensionI18n();
getMessageMock.mockReturnValue(`${count} items`);
const actual = i18n.tp('key', count, [String(count)]);
expect(actual).toBe(expected);
},
);
it.each([
[0, '0 items'],
[1, '1 item'],
[2, '2 items'],
[3, '3 items'],
])(
'should return the correct string for format "{1} | {n}" and count=%s',
(count, expected) => {
const i18n = createExtensionI18n();
getMessageMock.mockReturnValue(`1 item | ${count} items`);
const actual = i18n.tp('key', count, [String(count)]);
expect(actual).toBe(expected);
},
);
it.each([
[0, '0 items'],
[1, '1 item'],
[2, '2 items'],
[3, '3 items'],
])(
'should return the correct string for format "{0} | {1} | {n}" and count=%s',
(count, expected) => {
const i18n = createExtensionI18n();
getMessageMock.mockReturnValue(`0 items | 1 item | ${count} items`);
const actual = i18n.tp('key', count, [String(count)]);
expect(actual).toBe(expected);
},
);
it('should pass substitutions in correctly', () => {
const i18n = createExtensionI18n();
const key = 'key';
const count = 4;
const subs = ['1', 'two'];
getMessageMock.mockReturnValue(`0 items | 1 item | ${count} items`);
i18n.tp(key, count, subs);
expect(getMessageMock).toBeCalledWith(key, subs);
});
});
it('should support custom types', () => {
const i18n = createExtensionI18n<{
t: {
singularNoSubs: undefined;
singularOneSub: [name: string];
singularTwoSubs: [string, string];
};
tp: {
pluralNoSubs: undefined;
pluralOneSub: [name: string];
pluralTwoSubs: [string, string];
};
}>();
getMessageMock.mockReturnValue('');
i18n.t('singularNoSubs');
// @ts-expect-error
i18n.t('singularNoSubs', ['1', '2']);
i18n.t('singularOneSub', ['one']);
// @ts-expect-error
i18n.t('singularOneSub');
i18n.t('singularTwoSubs', ['one', 'two']);
// @ts-expect-error
i18n.t('singularTwoSubs', ['one']);
// @ts-expect-error
i18n.t('singularTwoSubs');
// @ts-expect-error
i18n.t('pluralNoSubs');
// @ts-expect-error
i18n.t('pluralOneSub');
// @ts-expect-error
i18n.t('pluralTwoSubs');
i18n.tp('pluralNoSubs', 0);
// @ts-expect-error
i18n.tp('pluralNoSubs', 0, ['1', '2']);
// @ts-expect-error
i18n.tp('pluralNoSubs');
i18n.tp('pluralOneSub', 0, ['one']);
// @ts-expect-error
i18n.tp('pluralOneSub', 0);
i18n.tp('pluralTwoSubs', 0, ['one', 'two']);
// @ts-expect-error
i18n.tp('pluralTwoSubs', 0, ['one']);
// @ts-expect-error
i18n.tp('pluralTwoSubs', 0);
// @ts-expect-error
i18n.tp('singularNoSubs');
// @ts-expect-error
i18n.tp('singularOneSub');
// @ts-expect-error
i18n.tp('singularTwoSubs');
});
});
-13
View File
@@ -1,13 +0,0 @@
simple: Hello world!
manifestStyle:
message: This is the translated text
description: This is a description to give translators more context (it's never translated)
interpolation: Hello $1, my name is $2
some:
nested:
translation: You can nest translations inside deep objects
escapeTheDollarSign: You owe me $$100
pluralForm:
n: $1 items
1: 1 item
0: Zero items
-21
View File
@@ -1,21 +0,0 @@
{
"simple": {
"message": "Hello world!"
},
"manifestStyle": {
"message": "This is the translated text",
"description": "This is a description to give translators more context (it's never translated)"
},
"interpolation": {
"message": "Hello $1, my name is $2"
},
"some_nested_translation": {
"message": "You can nest translations inside deep objects"
},
"escapeTheDollarSign": {
"message": "You owe me $$100"
},
"pluralForm": {
"message": "Zero items | 1 item | $1 items"
}
}
-87
View File
@@ -1,87 +0,0 @@
[
{
"entry": {
"message": "Hello world!"
},
"name": "simple"
},
{
"entry": {
"description": "This is a description to give translators more context (it's never translated)",
"message": "This is the translated text"
},
"name": "manifestStyle"
},
{
"entry": {
"message": "Hello $1, my name is $2"
},
"name": "interpolation"
},
{
"entry": {
"message": "You can nest translations inside deep objects"
},
"name": "some_nested_translation"
},
{
"entry": {
"message": "You owe me $$100"
},
"name": "escapeTheDollarSign"
},
{
"entry": {
"message": "Zero items | 1 item | $1 items"
},
"isPlural": true,
"name": "pluralForm"
},
{
"entry": {
"description": "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.\nNote: You can't use this message in a manifest file.",
"message": "<browser.runtime.id>"
},
"isBuiltin": true,
"name": "@@extension_id"
},
{
"entry": {
"message": "<browser.i18n.getUiLocale()>"
},
"isBuiltin": true,
"name": "@@ui_locale"
},
{
"entry": {
"description": "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.",
"message": "<ltr|rtl>"
},
"isBuiltin": true,
"name": "@@bidi_dir"
},
{
"entry": {
"description": "If the @@bidi_dir is \"ltr\", then this is \"rtl\"; otherwise, it's \"ltr\".",
"message": "<rtl|ltr>"
},
"isBuiltin": true,
"name": "@@bidi_reversed_dir"
},
{
"entry": {
"description": "If the @@bidi_dir is \"ltr\", then this is \"left\"; otherwise, it's \"right\".",
"message": "<left|right>"
},
"isBuiltin": true,
"name": "@@bidi_start_edge"
},
{
"entry": {
"description": "If the @@bidi_dir is \"ltr\", then this is \"right\"; otherwise, it's \"left\".",
"message": "<right|left>"
},
"isBuiltin": true,
"name": "@@bidi_end_edge"
}
]
-29
View File
@@ -1,29 +0,0 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { describe, it, expect } from 'vitest';
import { convertMessagesToManifest, readMessagesFile } from '../node';
const inputFile = resolve(__dirname, 'fixtures/input.yml');
const messagesFile = resolve(__dirname, 'fixtures/messages.json');
const manifestFile = resolve(__dirname, 'fixtures/manifest.json');
describe('I18n Node Utils', () => {
describe('readMessagesFile', () => {
it('should return all available messages and metadata', async () => {
const expected = JSON.parse(await readFile(messagesFile, 'utf-8'));
const actual = await readMessagesFile(inputFile);
expect(actual).toEqual(expected);
});
});
describe('convertMessagesToManifest', () => {
it('should return all available messages and metadata', async () => {
const input = JSON.parse(await readFile(messagesFile, 'utf-8'));
const expected = JSON.parse(await readFile(manifestFile, 'utf-8'));
const actual = convertMessagesToManifest(input);
expect(actual).toEqual(expected);
});
});
});
-75
View File
@@ -1,75 +0,0 @@
import { browser } from '~/browser';
export interface DefaultMessageSchema {
t: {
[key: string]: string[] | undefined;
};
tp: {
[key: string]: string[] | undefined;
};
}
export function createExtensionI18n<
TMessageSchema = unknown,
>(): ExtensionI18n<TMessageSchema> {
const untyped: UntypedExtensionI18n = {
t(key, substitutions) {
return browser.i18n.getMessage(key, substitutions);
},
tp(key, count, substitutions) {
const plural = browser.i18n
.getMessage(key as string, substitutions)
.split(' | ');
// "n items"
if (plural.length === 1) return plural[0];
// "1 item | n items"
if (plural.length === 2) {
if (count === 1) return plural[0];
return plural[1];
}
// "0 items | 1 item | n items"
if (count === 0 || count === 1) return plural[count];
return plural[2];
},
};
return untyped as ExtensionI18n<TMessageSchema>;
}
export type ExtensionI18n<TMessageSchema> =
TMessageSchema extends DefaultMessageSchema
? TypedExtensionI18n<TMessageSchema>
: UntypedExtensionI18n;
export interface TypedExtensionI18n<
TMessageSchema extends DefaultMessageSchema,
> {
t<TKey extends KeysWithoutSub<TMessageSchema['t']>>(key: TKey): string;
t<TKey extends KeysWithSub<TMessageSchema['t']>>(
key: TKey,
substitutions: TMessageSchema['t'][TKey],
): string;
tp<TKey extends KeysWithoutSub<TMessageSchema['tp']>>(
key: TKey,
count: number,
): string;
tp<TKey extends KeysWithSub<TMessageSchema['tp']>>(
key: TKey,
count: number,
substitutions: TMessageSchema['tp'][TKey],
): string;
}
export interface UntypedExtensionI18n {
t(key: string, substitutions?: string[]): string;
tp(key: string, count: number, substitutions?: string[]): string;
}
type FilterKeys<TObject, TFilterType> = {
[K in keyof TObject]-?: TObject[K] extends TFilterType ? K : never;
}[keyof TObject];
type KeysWithSub<TObject> = FilterKeys<TObject, string[]>;
type KeysWithoutSub<TObject> = FilterKeys<TObject, undefined>;
-21
View File
@@ -1,21 +0,0 @@
/**
* Simple, type-safe alternative to `browser.i18n.getMessage` with support for placeholders and plurals.
*
* See [the guide](https://wxt.dev/guide/localization.html) for more information.
*
* @module wxt/i18n
*/
import { DefaultMessageSchema, createExtensionI18n } from './client';
export interface WxtMessageSchema extends DefaultMessageSchema {
// Overriden per-project
}
export const i18n = createExtensionI18n<WxtMessageSchema>();
export {
DefaultMessageSchema,
ExtensionI18n,
TypedExtensionI18n,
UntypedExtensionI18n,
} from './client';
-210
View File
@@ -1,210 +0,0 @@
import { readFile } from 'node:fs/promises';
import JSON5 from 'json5';
import YAML from 'yaml';
/**
* Convert a file containing localized text in WXT's custom format into valid extension manifest
* format.
*/
export async function readMessagesFile(file: string): Promise<Message[]> {
const text = await readFile(file, 'utf-8');
return readMessagesText(text);
}
/**
* Convert a string containing localized text in WXT's custom format into valid extension manifest
* format.
*/
export function readMessagesText(text: string): Message[] {
const parsers: Array<(text: string) => any> = [
JSON.parse,
JSON5.parse,
YAML.parse,
];
for (const parse of parsers) {
try {
const result = parse(text);
if (typeof result === 'object') {
return readMessagesObject(result);
}
} catch {
continue;
}
}
throw Error('I18n messages text is not valid JSON, JSON5, or YAML');
}
/**
* Convert an object containing localized text in WXT's custom format into valid extension manifest
* format.
*/
export function readMessagesObject(input: ExtensionI18nSchema): Message[] {
const messagesFromInput = findEntries([], input);
return [...messagesFromInput, ...PREDEFINED_MESSAGES];
}
export function convertMessagesToManifest(
messages: Message[],
): RawExtensionI18nSchema {
return messages
.filter((message) => !message.isBuiltin)
.reduce<RawExtensionI18nSchema>((schema, { name, entry }) => {
schema[name] = entry;
return schema;
}, {});
}
function findEntries(
keyPath: string[],
input: ExtensionI18nSchema | ExtensionI18nEntry,
): Message[] {
const name = keyPath.join('_');
if (isBasicEntry(input))
return [
{
name,
entry: { message: input },
},
];
if (isManifestEntry(input))
return [
{
name,
entry: input,
},
];
if (isPluralEntry(input))
return [
{
name,
entry: {
message: Object.values(input).join(' | '),
},
isPlural: true,
},
];
return Object.entries(input).reduce<Message[]>((items, [key, child]) => {
const nestedEntries = findEntries(keyPath.concat(key), child);
return [...items, ...nestedEntries];
}, []);
}
function isBasicEntry(
entry: ExtensionI18nSchema | ExtensionI18nEntry,
): entry is ExtensionI18nBasicEntry {
return typeof entry === 'string';
}
function isManifestEntry(
entry: ExtensionI18nSchema | ExtensionI18nEntry,
): entry is ExtensionI18nManifestEntry {
const keys = Object.keys(entry);
if (keys.length < 1 || keys.length > 3) return false;
const knownKeys = new Set(['message', 'placeholders', 'description']);
const unknownKeys = keys.filter((key) => !knownKeys.has(key));
return unknownKeys.length === 0;
}
function isPluralEntry(
entry: ExtensionI18nSchema | ExtensionI18nEntry,
): entry is ExtensionI18nPluralEntry {
const keys = Object.keys(entry);
if (keys.length === 0) return false;
const invalidKeys = keys.filter((key) => key !== 'n' && isNaN(Number(key)));
return invalidKeys.length === 0;
}
export const PREDEFINED_MESSAGES: Message[] = [
{
name: '@@extension_id',
isBuiltin: true,
entry: {
message: '<browser.runtime.id>',
description:
"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.\nNote: You can't use this message in a manifest file.",
},
},
{
name: '@@ui_locale',
isBuiltin: true,
entry: {
message: '<browser.i18n.getUiLocale()>',
},
},
{
name: '@@bidi_dir',
isBuiltin: true,
entry: {
message: '<ltr|rtl>',
description:
'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.',
},
},
{
name: '@@bidi_reversed_dir',
isBuiltin: true,
entry: {
message: '<rtl|ltr>',
description:
'If the @@bidi_dir is "ltr", then this is "rtl"; otherwise, it\'s "ltr".',
},
},
{
name: '@@bidi_start_edge',
isBuiltin: true,
entry: {
message: '<left|right>',
description:
'If the @@bidi_dir is "ltr", then this is "left"; otherwise, it\'s "right".',
},
},
{
name: '@@bidi_end_edge',
isBuiltin: true,
entry: {
message: '<right|left>',
description:
'If the @@bidi_dir is "ltr", then this is "right"; otherwise, it\'s "left".',
},
},
];
export type RawExtensionI18nSchema = Record<string, RawExtensionI18nEntry>;
export interface RawExtensionI18nEntry {
message: string;
description?: string;
placeholders?: Record<
string,
{
content: string;
example?: string;
}
>;
}
export type ExtensionI18nBasicEntry = string;
export type ExtensionI18nManifestEntry = RawExtensionI18nEntry;
export type ExtensionI18nPluralEntry = {
n: string;
[i: number]: string;
};
export type ExtensionI18nEntry =
| ExtensionI18nBasicEntry
| ExtensionI18nManifestEntry
| ExtensionI18nPluralEntry;
export interface ExtensionI18nSchema {
[name: string]: ExtensionI18nSchema | ExtensionI18nEntry;
}
export interface Message {
name: string;
entry: RawExtensionI18nEntry;
isPlural?: boolean;
isBuiltin?: boolean;
}
+11 -1
View File
@@ -308,10 +308,20 @@ function createDriver(
storageArea: 'local' | 'session' | 'sync' | 'managed',
): WxtStorageDriver {
const getStorageArea = () => {
if (browser.storage == null)
if (browser.runtime == null) {
throw Error(
[
"'wxt/storage' must be loaded in a web extension environment",
'\n - If thrown during a build, see https://github.com/wxt-dev/wxt/issues/371',
" - If thrown during tests, mock 'wxt/browser' correctly. See https://wxt.dev/guide/testing.html\n",
].join('\n'),
);
}
if (browser.storage == null) {
throw Error(
"You must add the 'storage' permission to your manifest to use 'wxt/storage'",
);
}
return browser.storage[storageArea];
};
+58 -10
View File
@@ -27,16 +27,16 @@ export interface InlineConfig {
* @default "${config.root}/public"
*/
publicDir?: string;
/**
* Directory containing localization files used for translation.
*
* @default "${config.srcDir}/locales"
*/
localesDir?: string;
/**
* @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.
*
@@ -143,9 +143,22 @@ export interface InlineConfig {
* @default config.root
*/
sourcesRoot?: string;
/**
* [Minimatch](https://www.npmjs.com/package/minimatch) patterns of files to include when
* creating a ZIP of all your source code for Firefox. Patterns are relative to your
* `config.zip.sourcesRoot`.
*
* This setting overrides `excludeSources`. So if a file matches both lists, it is included in the ZIP.
*
* @example
* [
* "coverage", // Ignore the coverage directory in the `sourcesRoot`
* ]
*/
includeSources?: string[];
/**
* [Minimatch](https://www.npmjs.com/package/minimatch) patterns of files to exclude when
* creating a ZIP of all your source code for Firfox. Patterns are relative to your
* creating a ZIP of all your source code for Firefox. Patterns are relative to your
* `config.zip.sourcesRoot`.
*
* Hidden files, node_modules, and tests are ignored by default.
@@ -155,7 +168,7 @@ export interface InlineConfig {
* "coverage", // Ignore the coverage directory in the `sourcesRoot`
* ]
*/
ignoredSources?: string[];
excludeSources?: string[];
};
/**
@@ -229,6 +242,24 @@ export interface InlineConfig {
*/
includeBrowserPolyfill?: boolean;
};
/**
* Config effecting dev mode only.
*/
dev?: {
/**
* Controls whether a custom keyboard shortcut command, `Alt+R`, is added during dev mode to
* quickly reload the extension.
*
* If false, the shortcut is not added during development.
*
* If set to a custom string, you can override the key combo used. See
* [Chrome's command docs](https://developer.chrome.com/docs/extensions/reference/api/commands)
* for available options.
*
* @default "Alt+R"
*/
reloadCommand?: string | false;
};
}
// TODO: Extract to @wxt/vite-builder and use module augmentation to include the vite field
@@ -290,16 +321,24 @@ export interface BuildStepOutput {
}
export interface WxtDevServer
extends Omit<WxtBuilderServer, 'listen'>,
extends Omit<WxtBuilderServer, 'listen' | 'close'>,
ServerInfo {
/**
* Stores the current build output of the server.
*/
currentOutput: BuildOutput;
currentOutput: BuildOutput | undefined;
/**
* Start the server.
*/
start(): Promise<void>;
/**
* Stop the server.
*/
stop(): Promise<void>;
/**
* Close the browser, stop the server, rebuild the entire extension, and start the server again.
*/
restart(): Promise<void>;
/**
* Transform the HTML for dev mode.
*/
@@ -332,6 +371,10 @@ export interface WxtDevServer
reloadContentScript: (
contentScript: Omit<Scripting.RegisteredContentScript, 'id'>,
) => void;
/**
* Grab the latest runner config and restart the browser.
*/
restartBrowser: () => void;
}
export type TargetBrowser = string;
@@ -383,6 +426,7 @@ export interface BaseEntrypoint {
*/
outputDir: string;
options: BaseEntrypointOptions;
skipped: boolean;
}
export interface GenericEntrypoint extends BaseEntrypoint {
@@ -673,6 +717,10 @@ export interface WxtBuilderServer {
* Start the server.
*/
listen(): Promise<void>;
/**
* Stop the server.
*/
close(): Promise<void>;
/**
* Transform the HTML for dev mode.
*/
+7 -4
View File
@@ -1,7 +1,10 @@
// Globals defined by the vite-plugins/devServerGlobals.ts and utils/globals.ts
declare const __COMMAND__: 'build' | 'serve';
declare const __DEV_SERVER_PROTOCOL__: string;
declare const __DEV_SERVER_HOSTNAME__: string;
declare const __DEV_SERVER_PORT__: string;
declare const __MANIFEST_VERSION__: 2 | 3;
declare const __ENTRYPOINT__: string;
// Globals defined by the vite-plugins/devServerGlobals.ts and utils/globals.ts
interface ImportMetaEnv {
readonly COMMAND: 'build' | 'serve';
readonly MANIFEST_VERSION: 2 | 3;
readonly ENTRYPOINT: string;
}
+6 -2
View File
@@ -18,10 +18,10 @@ export interface InternalConfig {
root: string;
srcDir: string;
publicDir: string;
localesDir: string;
wxtDir: string;
typesDir: string;
entrypointsDir: string;
filterEntrypoints?: Set<string>;
outBaseDir: string;
outDir: string;
debug: boolean;
@@ -40,7 +40,8 @@ export interface InternalConfig {
name?: string;
artifactTemplate: string;
sourcesTemplate: string;
ignoredSources: string[];
includeSources: string[];
excludeSources: string[];
sourcesRoot: string;
};
transformManifest: (manifest: Manifest.WebExtensionManifest) => void;
@@ -57,6 +58,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;
+2 -2
View File
@@ -5,7 +5,7 @@ import { browser } from 'wxt/browser';
import { keepServiceWorkerAlive } from './utils/keep-service-worker-alive';
import { reloadContentScript } from './utils/reload-content-scripts';
if (__COMMAND__ === 'serve') {
if (import.meta.env.COMMAND === 'serve') {
try {
const ws = setupWebSocket((message) => {
if (message.event === 'wxt:reload-extension') browser.runtime.reload();
@@ -13,7 +13,7 @@ if (__COMMAND__ === 'serve') {
reloadContentScript(message.data);
});
if (__MANIFEST_VERSION__ === 3) {
if (import.meta.env.MANIFEST_VERSION === 3) {
// Tell the server the background script is loaded and ready to go
ws.addEventListener('open', () => {
const msg = { type: 'custom', event: 'wxt:background-initialized' };
@@ -5,12 +5,12 @@ import { ContentScriptContext } from 'wxt/client';
(async () => {
try {
const { main, ...options } = definition;
const ctx = new ContentScriptContext(__ENTRYPOINT__, options);
const ctx = new ContentScriptContext(import.meta.env.ENTRYPOINT, options);
await main(ctx);
} catch (err) {
logger.error(
`The content script "${__ENTRYPOINT__}" crashed on startup!`,
`The content script "${import.meta.env.ENTRYPOINT}" crashed on startup!`,
err,
);
}
@@ -7,7 +7,7 @@ import { logger } from '../sandbox/utils/logger';
await main();
} catch (err) {
logger.error(
`The content script "${__ENTRYPOINT__}" crashed on startup!`,
`The content script "${import.meta.env.ENTRYPOINT}" crashed on startup!`,
err,
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { logger } from '../sandbox/utils/logger';
import { setupWebSocket } from './utils/setup-web-socket';
if (__COMMAND__ === 'serve') {
if (import.meta.env.COMMAND === 'serve') {
try {
setupWebSocket((message) => {
if (message.event === 'wxt:reload-page') {
+1 -1
View File
@@ -6,7 +6,7 @@ import { logger } from '../sandbox/utils/logger';
await definition.main();
} catch (err) {
logger.error(
`The unlisted script "${__ENTRYPOINT__}" crashed on startup!`,
`The unlisted script "${import.meta.env.ENTRYPOINT}" crashed on startup!`,
err,
);
}