Compare commits

...

31 Commits

Author SHA1 Message Date
Aaron Klinker 98bc1f2cd1 Bump version to alpha2 2024-01-14 16:27:38 -06:00
Aaron Klinker f6a066f166 Fix tests 2024-01-14 16:26:54 -06:00
Aaron Klinker c20478cde2 Bump version to 0.14.2-alpha1 2024-01-14 16:19:54 -06:00
Aaron Klinker ebd50079a7 Cleanup unsed imports 2024-01-14 16:18:47 -06:00
Aaron Klinker b17bd87865 Add docs 2024-01-14 16:18:19 -06:00
Aaron Klinker e8b471e23f Improve typing and add tests for it 2024-01-14 15:05:52 -06:00
Aaron Klinker 32ffca4168 Add client tests 2024-01-14 14:49:15 -06:00
Aaron Klinker e9c573d5a1 Implement and generate types for wxt/i18n 2024-01-12 23:58:55 -06:00
Aaron Klinker ae5126d726 Update lockfile 2024-01-12 22:49:13 -06:00
Aaron Klinker 24a77ca9b5 Add i18n module to named export list 2024-01-12 22:49:03 -06:00
Aaron Klinker 174cbbc5eb Add i18n module to auto-imports 2024-01-12 22:45:58 -06:00
Aaron Klinker 607c70dd56 Build wxt/i18n 2024-01-12 22:45:41 -06:00
Aaron Klinker 616d012326 Update demo code for testing 2024-01-12 22:45:18 -06:00
Aaron Klinker 75b6ca7172 Add conversion code and tests 2024-01-12 22:45:04 -06:00
Aaron Klinker 1497b3099c Move demo files 2024-01-12 22:44:33 -06:00
Aaron Klinker a329e24818 chore: Fix typo in function name 2024-01-12 13:03:43 -06:00
Aaron 249cf63da8 perf: Only call findEntrypoint once per build (#342) 2024-01-12 13:02:44 -06:00
Aaron Klinker 70a496108a chore: Update templates to ^0.14.0 2024-01-12 08:53:32 -06:00
GitHub Actions 214889e052 chore(release): v0.14.1 2024-01-12 14:50:29 +00:00
Aaron Klinker b6ab7a9567 fix: Use Alt+R/Opt+R to reload extension during development
This closes #339
2024-01-12 08:47:21 -06:00
Aaron Klinker 1ceb9e2e80 Add missing breaking change to changelog 2024-01-11 17:00:43 -06:00
Aaron Klinker 15ea81514f Update changelog 2024-01-11 13:28:41 -06:00
GitHub Actions 21d4e6dede chore(release): v0.14.0 2024-01-11 17:17:23 +00:00
Aaron 5343430c0f feat!: Refactor content script UI functions and add helper for "integrated" UIs (#333)
BREAKING CHANGES: Several APIs/options have been renamed:

- `type: "inline" | "overlay" | "modal"` has been changed to `position: "inline" | "overlay" | "modal"`
- `createContentScriptUi({ ... })` → `createShadowRootUi({ ... })`
- `createContentScriptIframe({ ... })` → `createIframeUi({ ... })`
- `onRemove` is now called ***before*** the UI is removed from the DOM, previously it was called after the UI was removed
2024-01-10 20:48:17 -06:00
GitHub Actions 8c834835c4 chore(release): v0.13.5 2024-01-08 20:10:48 +00:00
Aaron 9a2cc18f64 chore: Update bug report template 2024-01-08 13:50:20 -06:00
Aaron Klinker 8683bd4b77 docs: Add section about customizing other browser options during development 2024-01-08 13:42:50 -06:00
Aaron dca52864bb fix: Strip path from web_accessible_resources[0].matches (#332) 2024-01-08 13:17:31 -06:00
GitHub Actions 0c5643f648 chore(release): v0.13.4 2024-01-07 16:51:31 +00:00
Aaron Klinker b7cdf1597c fix: Disable minification during development
This closes #328
2024-01-07 10:48:25 -06:00
Aaron Klinker 2770974261 chore: Use const instead of let 2024-01-07 10:46:49 -06:00
60 changed files with 2262 additions and 1323 deletions
+14 -6
View File
@@ -12,14 +12,22 @@ A clear and concise description of what the bug is.
### To Reproduce
Upload a ZIP or share a link to a repo representing the minimal reproduction. **_If you don't upload a minimal reproduction, you bug report will be closed._**
Share minimal reproduction. Examples of a minimal reproduction:
Steps to reproduce the behavior:
- PR with a failing test case
- ZIP file containing a minimal WXT project (be sure to include your lockfile!!)
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
> **_⚠️ If you don't upload a minimal reproduction, your issue will be closed until a reproduction is added._**
>
> Why? https://antfu.me/posts/why-reproductions-are-required
Steps to reproduce the bug using the reproduction:
1. Install dependencies: `pnpm i`
2. Start dev mode: `pnpm dev`
3. Click this...
4. Do that...
5. Etc...
### Expected behavior
+54
View File
@@ -1,5 +1,59 @@
# Changelog
## v0.14.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.0...v0.14.1)
### 🩹 Fixes
- Use `Alt+R`/`Opt+R` to reload extension during development ([b6ab7a9](https://github.com/wxt-dev/wxt/commit/b6ab7a9))
## v0.14.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.5...v0.14.0)
### 🚀 Enhancements
- ⚠️ Refactor content script UI functions and add helper for "integrated" UIs ([#333](https://github.com/wxt-dev/wxt/pull/333))
#### ⚠️ Breaking Changes
`createContentScriptUi` and `createContentScriptIframe`, and some of their options, have been renamed:
- `createContentScriptUi({ ... })` → `createShadowRootUi({ ... })`
- `createContentScriptIframe({ ... })` → `createIframeUi({ ... })`
- `type: "inline" | "overlay" | "modal"` has been changed to `position: "inline" | "overlay" | "modal"`
- `onRemove` is now called ***before*** the UI is removed from the DOM, previously it was called after the UI was removed
- `mount` option has been renamed to `onMount`, to better match the related option, `onRemove`.
## v0.13.5
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.4...v0.13.5)
### 🩹 Fixes
- Strip path from `web_accessible_resources[0].matches` ([#332](https://github.com/wxt-dev/wxt/pull/332))
### 📖 Documentation
- Add section about customizing other browser options during development ([8683bd4](https://github.com/wxt-dev/wxt/commit/8683bd4))
### 🏡 Chore
- Update bug report template ([9a2cc18](https://github.com/wxt-dev/wxt/commit/9a2cc18))
## v0.13.4
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.3...v0.13.4)
### 🩹 Fixes
- Disable minification during development ([b7cdf15](https://github.com/wxt-dev/wxt/commit/b7cdf15))
### 🏡 Chore
- Use `const` instead of `let` ([2770974](https://github.com/wxt-dev/wxt/commit/2770974))
## v0.13.3
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.2...v0.13.3)
+20 -10
View File
@@ -1,5 +1,3 @@
import messages from 'public/_locales/en/messages.json';
export default defineBackground(() => {
console.log(browser.runtime.id);
logId();
@@ -8,20 +6,32 @@ export default defineBackground(() => {
chrome: __IS_CHROME__,
firefox: __IS_FIREFOX__,
manifestVersion: __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');
// @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([
// @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']),
]);
console.log('WXT MODE:', {
MODE: import.meta.env.MODE,
+2 -2
View File
@@ -2,9 +2,9 @@ export default defineContentScript({
matches: ['*://*.google.com/*'],
main(ctx) {
const ui = createContentScriptIframe(ctx, {
const ui = createIframeUi(ctx, {
page: '/iframe-src.html',
type: 'overlay',
position: 'overlay',
anchor: 'form[action="/search"]',
});
ui.mount();
+3 -3
View File
@@ -6,12 +6,12 @@ export default defineContentScript({
cssInjectionMode: 'ui',
async main(ctx) {
const ui = await createContentScriptUi(ctx, {
const ui = await createShadowRootUi(ctx, {
name: 'demo-ui',
type: 'inline',
position: 'inline',
append: 'before',
anchor: 'form[role=search]',
mount: (container) => {
onMount: (container) => {
const app = document.createElement('div');
app.textContent = 'Custom content script UI';
container.append(app);
+21
View File
@@ -0,0 +1,21 @@
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
@@ -1,29 +0,0 @@
{
"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,6 +89,7 @@ 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' },
+3 -1
View File
@@ -14,7 +14,9 @@ Some WXT APIs can be used without importing them:
- [`defineContentScript`](/api/wxt/sandbox/functions/defineContentScript) from `wxt/sandbox`
- [`defineBackground`](/api/wxt/sandbox/functions/defineBackground) from `wxt/sandbox`
- [`defineUnlistedScript`](/api/wxt/sandbox/functions/defineUnlistedScript) from `wxt/sandbox`
- [`createContentScriptUi`](/api/wxt/client/functions/createContentScriptUi) from `wxt/client`
- [`createIntegratedUi`](/api/wxt/client/functions/createIntegratedUi) from `wxt/client`
- [`createShadowRootUi`](/api/wxt/client/functions/createShadowRootUi) from `wxt/client`
- [`createIframeUi`](/api/wxt/client/functions/createIframeUi) from `wxt/client`
- [`fakeBrowser`](/api/wxt/testing/variables/fakeBrowser) from `wxt/testing`
And more!
+207 -137
View File
@@ -6,35 +6,35 @@ There are three ways to mount a UI inside a content script:
Each has their own set of advantages and disadvantages.
| Method | Isolated Styles | Isolated Events | HMR | Use page's context |
| ---------- | :-------------: | :-----------------: | :-: | :----------------: |
| Integrated | ❌ | ❌ | ❌ | ✅ |
| ShadowRoot | ✅ | ✅ (off by default) | ❌ | ✅ |
| IFrame | ✅ | ✅ | ✅ | ❌ |
| Method | Isolated Styles | Isolated Events | HMR | Use page's context |
| ----------- | :-------------: | :-----------------: | :-: | :----------------: |
| Integrated | ❌ | ❌ | ❌ | ✅ |
| Shadow Root | ✅ | ✅ (off by default) | ❌ | ✅ |
| IFrame | ✅ | ✅ | ✅ | ❌ |
## Integrated
Integrated content script UIs are injected alongside the content of a page. This means that they are affected by CSS on that page.
You can control how CSS is injected for an integrated content script UI with the [`cssInjectionMode`](/api/wxt/interfaces/ContentScriptBaseDefinition#cssinjectionmode) property.
:::code-group
```ts [Vanilla]
// entrypoints/example-ui.content.ts
export default defineContentScript({
main(ctx) {
// Create the UI container
const container = document.createElement('div');
// Add UI container to the page
const anchor = document.querySelector('#anchor');
anchore.append(container);
// Remove UI container when invalidated
ctx.onInvalidated(() => {
container.remove();
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
// Append children to the container
const app = document.createElement('p');
app.textContent = '...';
container.append(app);
},
});
// Call mount to add the UI to the DOM
ui.mount();
},
});
```
@@ -42,25 +42,27 @@ export default defineContentScript({
```ts [Vue]
// entrypoints/example-ui.content/index.ts
import { createApp } from 'vue';
import App from './App.vue';
export default defineContentScript({
main(ctx) {
// Create the UI container
const container = document.createElement('div');
// Create the app and mount it to the UI container
const app = createApp(...);
app.mount(container);
// Add UI container to the page
const anchor = document.querySelector('#anchor');
anchore.append(container);
// Unmount the app and remove UI container when invalidated
ctx.onInvalidated(() => {
app.unmount();
container.remove();
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
// Create the app and mount it to the UI container
const app = createApp(App);
app.mount(container);
return app;
},
onRemove: (app) => {
// Unmount the app when the UI is removed
app.unmount();
},
});
// Call mount to add the UI to the DOM
ui.mount();
},
});
```
@@ -68,25 +70,27 @@ export default defineContentScript({
```tsx [React]
// entrypoints/example-ui.content/index.tsx
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
export default defineContentScript({
main(ctx) {
// Create the UI container
const container = document.createElement('div');
// Create a root on the UI container and render a component
const root = ReactDOM.createRoot(container);
root.render(...);
// Add UI container to the page
const anchor = document.querySelector('#anchor');
anchore.append(container);
// Unmount the root and remove UI container when invalidated
ctx.onInvalidated(() => {
root.unmount();
container.remove();
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
// Create a root on the UI container and render a component
const root = ReactDOM.createRoot(container);
root.render(<App />);
return root;
},
onRemove: (root) => {
// Unmount the root when the UI is removed
root.unmount();
},
});
// Call mount to add the UI to the DOM
ui.mount();
},
});
```
@@ -97,23 +101,24 @@ import App from './App.svelte';
export default defineContentScript({
main(ctx) {
// Create the UI container
const container = document.createElement('div');
// Create the Svelte app inside the UI container
const app = new App({
target: ui,
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
// Create the Svelte app inside the UI container
const app = new App({
target: container,
});
return app;
},
onRemove: (app) => {
// Destroy the app when the UI is removed
app.$destroy();
},
});
// Add UI container to the page
const anchor = document.querySelector('#anchor');
anchore.append(container);
// Destroy the app and remove UI container when invalidated
ctx.onInvalidated(() => {
app.$destroy();
container.remove();
});
// Call mount to add the UI to the DOM
ui.mount();
},
});
```
@@ -124,41 +129,47 @@ import { render } from 'solid-js/web';
export default defineContentScript({
main(ctx) {
// Create the UI container
const container = document.createElement('div');
// Render your app to the UI container
const unmount = render(() => ..., container)
// Add UI container to the page
const anchor = document.querySelector('#anchor');
anchore.append(container);
// Unmount the app and remove UI container when invalidated
ctx.onInvalidated(() => {
unmount();
container.remove();
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
// Render your app to the UI container
const unmount = render(() => <div>...</div>, container);
},
onRemove: (unmount) => {
// Unmount the app when the UI is removed
unmount();
},
});
// Call mount to add the UI to the DOM
ui.mount();
},
});
```
:::
## ShadowRoot
See the [API Reference](/api/wxt/client/functions/createIntegratedUi) for the complete list of options.
You can control how CSS is injected for an integrated content script UI with the [`cssInjectionMode`](/api/wxt/interfaces/ContentScriptBaseDefinition#cssinjectionmode) property. Usually, you'll want to leave it as `"manifest"`, the default, so the UI inherits its style from the website's CSS.
## Shadow Root
Often in web extensions, you don't want your content script's CSS affecting the page, or vise-versa. The [`ShadowRoot`](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot) API is ideal for this.
WXT provides a helper function, [`createContentScriptUi`](/api/wxt/client/functions/createContentScriptUi), that abstracts all the `ShadowRoot` setup away, making it easy to create UIs with isolated CSS. It also supports an optional `isolateEvents` parameter to further isolate user interactions.
WXT's [`createShadowRootUi`](/api/wxt/client/functions/createShadowRootUi) abstracts all the `ShadowRoot` setup away, making it easy to create UIs with isolated CSS. It also supports an optional `isolateEvents` parameter to further isolate user interactions.
To use `createContentScriptUi`, follow these steps:
To use `createShadowRootUi`, follow these steps:
1. Import your CSS file at the top of your content script
2. Set [`cssInjectionMode: "ui"`](/api/wxt/interfaces/ContentScriptBaseDefinition#cssinjectionmode) inside `defineContentScript`
3. Define your UI with `createContentScriptUi()`
3. Define your UI with `createShadowRootUi()`
4. Mount the UI so it is visible to users
```ts
:::code-group
```ts [Vanilla]
// 1. Import the style
import './style.css';
@@ -168,11 +179,11 @@ export default defineContentScript({
async main(ctx) {
// 3. Define your UI
const ui = await createContentScriptUi(ctx, {
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
anchor: '#anchor',
type: 'inline',
mount(container) {
onMount(container) {
// Define how your UI will be mounted inside the container
const app = document.createElement('p');
app.textContent = 'Hello world!';
@@ -186,91 +197,149 @@ export default defineContentScript({
});
```
> `createContentScriptUi` will automatically remove the UI from the page when the content script is invalidated.
See the [API Reference](/api/wxt/client/functions/createContentScriptUi) for the complete list of options.
:::info TailwindCSS
`createContentScriptUi` supports TailwindCSS out of the box! When importing the styles, just import the main CSS file containing the `@tailwind` directives, and everything will just work :+1:.
:::
When using a frontend framework for your UI, you'll need to unmount the app when the UI is removed. This is accomplished by returning an app reference from the `mount` option and by passing in a custom `onRemoved` option:
:::code-group
```ts [Vue]
// 1. Import the style
import './style.css';
import { createApp } from 'vue';
import App from './App.vue';
const ui = createContentScriptUi(ctx, {
// ...
mount(container) {
const app = createApp(App);
app.mount(container);
return app;
},
onRemove(app) {
app.unmount();
export default defineContentScript({
// 2. Set cssInjectionMode
cssInjectionMode: 'ui',
async main(ctx) {
// 3. Define your UI
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
anchor: '#anchor',
type: 'inline',
onMount: (container) => {
// Define how your UI will be mounted inside the container
const app = createApp(App);
app.mount(container);
return app;
},
onRemove: (app) => {
// Unmount the app when the UI is removed
app.unmount();
},
});
// 4. Mount the UI
ui.mount();
},
});
```
```tsx [React]
// 1. Import the style
import './style.css';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
const ui = createContentScriptUi(ctx, {
// ...
mount(container) {
const root = ReactDOM.createRoot(container);
root.render(...);
return root;
},
onRemove(root) {
root.unmount();
export default defineContentScript({
// 2. Set cssInjectionMode
cssInjectionMode: 'ui',
main(ctx) {
// 3. Define your UI
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
// Create a root on the UI container and render a component
const root = ReactDOM.createRoot(container);
root.render(<App />);
return root;
},
onRemove: (root) => {
// Unmount the root when the UI is removed
root.unmount();
},
});
// 4. Mount the UI
ui.mount();
},
});
```
```ts [Svelte]
```ts [Svelete]
// 1. Import the style
import './style.css';
import App from './App.svelte';
const ui = createContentScriptUi(ctx, {
// ...
mount(container) {
return new App({ target: container });
},
onRemove(app) {
app.$destry();
export default defineContentScript({
// 2. Set cssInjectionMode
cssInjectionMode: 'ui',
main(ctx) {
// 3. Define your UI
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
// Create the Svelte app inside the UI container
const app = new App({
target: container,
});
return app;
},
onRemove: (app) => {
// Destroy the app when the UI is removed
app.$destroy();
},
});
// 4. Mount the UI
ui.mount();
},
});
```
```tsx [Solid]
// 1. Import the style
import './style.css';
import { render } from 'solid-js/web';
const ui = createContentScriptUi(ctx, {
// ...
mount(container) {
return render(() => ..., container);
},
onRemove(unmount) {
unmount();
export default defineContentScript({
// 2. Set cssInjectionMode
cssInjectionMode: 'ui',
main(ctx) {
// 3. Define your UI
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#anchor',
onMount: (container) => {
// Render your app to the UI container
const unmount = render(() => <div>...</div>, container);
},
onRemove: (unmount) => {
// Unmount the app when the UI is removed
unmount();
},
});
// 4. Mount the UI
ui.mount();
},
});
```
:::
:::warning
The `mount(container)` and `onRemove(app)` options passed into `createContentScriptUi` **_are different from_** the `ui.mount()` and `ui.remove()` functions available on the returned UI object.
See the [API Reference](/api/wxt/client/functions/createShadowRootUi) for the complete list of options.
You don't need to pass anything into `ui.mount()` and `ui.remove()` because **_you already defined how and where the UI will be mounted_** in the options passed into `createContentScriptUi`.
:::info TailwindCSS
`createShadowRootUi` supports TailwindCSS out of the box! When importing the styles, just import the main CSS file containing the `@tailwind` directives, and everything will just work :+1:.
:::
## IFrame
If you don't need to run your UI in the same frame as the content script, you can use an IFrame to host your UI instead. Since an IFrame just hosts an HTML page, **_HMR is supported_**.
WXT provides a helper function, [`createContentScriptIframe`](/api/wxt/client/functions/createContentScriptUi), which simplifies setting up the IFrame.
WXT provides a helper function, [`createIframeUi`](/api/wxt/client/functions/createIframeUi), which simplifies setting up the IFrame.
1. Create an HTML page that will be loaded into your IFrame
```html
@@ -308,19 +377,20 @@ WXT provides a helper function, [`createContentScriptIframe`](/api/wxt/client/fu
// ...
async main(ctx) {
// Define the UI
const ui = await createContentScriptIframe(ctx, {
const ui = await createIframeUi(ctx, {
page: '/example-iframe.html',
anchor: '#anchor',
type: 'inline',
onMount: (wrapper, iframe) => {
// Add styles to the iframe like width
iframe.width = 123;
},
});
// Add styles to the iframe like width
ui.iframe.width = 123;
// Show UI to user
ui.mount();
},
});
```
See the [API Reference](/api/wxt/client/functions/createContentScriptUi) for the complete list of options.
See the [API Reference](/api/wxt/client/functions/createIframeUi) for the complete list of options.
+19
View File
@@ -39,6 +39,8 @@ export default defineRunnerConfig({
:::
### Browser Binaries
`web-ext`'s browser discovery is very limitted. By default, it only guesses at where Chrome and Firefox are installed. If you've customized your install locations, you may need to tell `web-ext` where the binaries/executables are located using the [`binaries` option](/api/wxt/interfaces/ExtensionRunnerConfig#binaries). For other Chromium based browsers, like Edge or Opera, you'll need to explicitly list them in the `binaries` option as well, otherwise they will open in Chrome by default.
```ts
@@ -58,6 +60,23 @@ export default defineRunnerConfig({
When configuring browser binaries, it's helpful to put them in `~/web-ext.config.ts` instead of the project directory's `web-ext.config.ts` file. When placed in your home directory (`~/`), this config will be used by all WXT projects, so you only need to configure the binaries once.
:::
### Other options
You can customize other options as well, like startup URLs, profiles, or additional command line arguments:
```ts
// web-ext.config.ts
import { defineRunnerConfig } from 'wxt';
export default defineRunnerConfig({
startUrls: ['https://google.com', 'https://duckduckgo.com'],
chromiumProfile: '/path/to/profile/to/use',
chromiumArgs: ['--window-size=400x300'],
});
```
For a full list of options, see the [API Reference](/api/wxt/interfaces/ExtensionRunnerConfig).
## Reload the Extension
Normally, to manually reload an extension, you have to visit `chrome://extensions` and click the reload button for your extension.
+159
View File
@@ -0,0 +1,159 @@
---
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"
```
+2 -1
View File
@@ -5,7 +5,8 @@
"../src/browser.ts",
"../src/sandbox",
"../src/storage.ts",
"../src/testing"
"../src/testing",
"../src/i18n"
],
"plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme"],
"out": "./api",
+4 -2
View File
@@ -20,13 +20,15 @@ describe('Auto Imports', () => {
const InvalidMatchPattern: typeof import('wxt/sandbox')['InvalidMatchPattern']
const MatchPattern: typeof import('wxt/sandbox')['MatchPattern']
const browser: typeof import('wxt/browser')['browser']
const createContentScriptIframe: typeof import('wxt/client')['createContentScriptIframe']
const createContentScriptUi: typeof import('wxt/client')['createContentScriptUi']
const createIframeUi: typeof import('wxt/client')['createIframeUi']
const createIntegratedUi: typeof import('wxt/client')['createIntegratedUi']
const createShadowRootUi: typeof import('wxt/client')['createShadowRootUi']
const defineBackground: typeof import('wxt/sandbox')['defineBackground']
const defineConfig: typeof import('wxt')['defineConfig']
const defineContentScript: typeof import('wxt/sandbox')['defineContentScript']
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']
}
"
+51 -32
View File
@@ -61,7 +61,7 @@ describe('TypeScript Project', () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
project.addFile(
'public/_locales/en/messages.json',
'locales/en.json',
JSON.stringify({
prompt_for_name: {
message: "What's your name?",
@@ -119,6 +119,36 @@ 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.
@@ -131,7 +161,7 @@ describe('TypeScript Project', () => {
options?: GetMessageOptions,
): string;
/**
*
* No message description.
*
* "<browser.i18n.getUiLocale()>"
*/
@@ -180,36 +210,25 @@ describe('TypeScript Project', () => {
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* 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;
}
}
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: {
};
}
}
"
+12 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wxt",
"type": "module",
"version": "0.13.3",
"version": "0.14.2-alpha2",
"description": "Next gen framework for developing web extensions",
"engines": {
"node": ">=18",
@@ -77,6 +77,16 @@
"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,6 +145,7 @@
"vite": "^5.0.0",
"web-ext-run": "^0.1.0",
"webextension-polyfill": "^0.10.0",
"yaml": "^2.3.4",
"zip-dir": "^2.0.0"
},
"devDependencies": {
+3 -1
View File
@@ -104,6 +104,9 @@ importers:
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
@@ -5385,7 +5388,6 @@ 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==}
+1
View File
@@ -58,6 +58,7 @@ 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,
@@ -1,214 +0,0 @@
/** @vitest-environment happy-dom */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createContentScriptIframe } from '~/client/content-scripts/content-script-iframe';
import { ContentScriptContext } from '~/client/content-scripts/content-script-context';
const createCtx = () => new ContentScriptContext('test');
const fetch = vi.fn();
describe('createContentScriptIframe', () => {
beforeEach(() => {
document.body.innerHTML = `
<div id="parent">
<p id="one">one</p>
<p id="two">two</p>
<p id="three"></p>
</div>
`;
window.fetch = fetch;
fetch.mockResolvedValue({ text: () => Promise.resolve('') });
});
describe('mount', () => {
describe('append option', () => {
it.each([undefined, 'last' as const])(
'should append the element as the last child to the anchor when append=%s',
async (append) => {
const ui = await createContentScriptIframe(createCtx(), {
page: '/test.html',
type: 'inline',
anchor: '#parent',
append,
});
ui.mount();
expect(
document.querySelector('#parent > :nth-child(4)')?.tagName,
).toEqual('DIV');
},
);
it('should append the element as the first child to the anchor when append=first', async () => {
const ui = await createContentScriptIframe(createCtx(), {
page: '/test.html',
type: 'inline',
anchor: '#parent',
append: 'first',
});
ui.mount();
expect(
document.querySelector('#parent > :nth-child(1)')?.tagName,
).toEqual('DIV');
});
it('should append the element normally when append=first but there are no other children', async () => {
const ui = await createContentScriptIframe(createCtx(), {
page: '/test.html',
type: 'inline',
anchor: '#three',
append: 'first',
});
ui.mount();
expect(
document.querySelector('#three > :nth-child(1)')?.tagName,
).toEqual('DIV');
});
it('should replace the anchor with the element when append=replace', async () => {
const ui = await createContentScriptIframe(createCtx(), {
page: '/test.html',
type: 'inline',
anchor: '#two',
append: 'replace',
});
ui.mount();
expect(
document.querySelector('#parent > :nth-child(2)')?.tagName,
).toEqual('DIV');
});
it('should append the element after the anchor when append=before', async () => {
const ui = await createContentScriptIframe(createCtx(), {
page: '/test.html',
type: 'inline',
anchor: '#two',
append: 'before',
});
ui.mount();
expect(
document.querySelector('#parent > :nth-child(2)')?.tagName,
).toEqual('DIV');
});
it('should append the element after the anchor when append=after', async () => {
const ui = await createContentScriptIframe(createCtx(), {
page: '/test.html',
type: 'inline',
anchor: '#two',
append: 'after',
});
ui.mount();
// Happy DOM doesn't work in this case, so we just make sure the element is added.
// expect(
// document.querySelector('#parent > :nth-child(3)')?.tagName,
// ).toEqual('DIV');
expect(document.querySelector('DIV')).toBeDefined();
});
it('should apply a custom function', async () => {
const ui = await createContentScriptIframe(createCtx(), {
page: '/test.html',
type: 'inline',
anchor: '#three',
append: (anchor, ui) => anchor.replaceWith(ui),
});
ui.mount();
expect(
document.querySelector('#parent > :nth-child(3)')?.tagName,
).toEqual('DIV');
});
});
it('should default the anchor to the body when unset', async () => {
const ui = await createContentScriptIframe(createCtx(), {
page: '/test.html',
type: 'inline',
});
ui.mount();
expect(document.querySelector('body > :nth-child(2)')?.tagName).toEqual(
'DIV',
);
});
it.each(['#four', () => document.querySelector('#four')])(
"should throw an error if the anchor doesn't exist",
async (anchor) => {
const ui = await createContentScriptIframe(createCtx(), {
page: '/test.html',
type: 'inline',
anchor,
});
expect(ui.mount).toThrow(
'Failed to mount content script UI: could not find anchor element',
);
},
);
});
describe('remove', () => {
it("should not fail if the ui hasn't been mounted", async () => {
const ui = await createContentScriptIframe(createCtx(), {
page: '/test.html',
type: 'inline',
});
ui.remove();
});
it('should automatically remove the UI when the context is invalidated', async () => {
const ctx = createCtx();
const ui = await createContentScriptIframe(ctx, {
page: '/test.html',
type: 'inline',
});
ui.mount();
expect(document.querySelector('.wxt-iframe-wrapper')).toBeDefined();
ctx.abort();
expect(document.querySelector('.wxt-iframe-wrapper')).toBeNull();
});
});
describe('type', () => {
it.each(['inline', 'overlay', 'modal'] as const)(
'should render type=%s',
async (type) => {
const ui = await createContentScriptIframe(createCtx(), {
page: '/test.html',
type,
});
ui.mount();
expect(document.querySelector('DIV')).toBeDefined();
},
);
});
describe('anchor', () => {
it.each([
() => '#two',
() => () => '#two',
() => document.querySelector('#two'),
() => () => document.querySelector('#two'),
])('should render anchor=%s', async (getAnchor) => {
const ui = await createContentScriptIframe(createCtx(), {
page: '/test.html',
type: 'inline',
anchor: getAnchor(),
});
ui.mount();
expect(document.querySelector('DIV')).toBeDefined();
});
});
});
@@ -1,358 +0,0 @@
/** @vitest-environment happy-dom */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createContentScriptUi } from '~/client/content-scripts/content-script-ui';
import { ContentScriptContext } from '~/client/content-scripts/content-script-context';
import { createIsolatedElement } from '@webext-core/isolated-element';
import { mock } from 'vitest-mock-extended';
import { browser } from '~/browser';
import { faker } from '@faker-js/faker';
vi.mock('@webext-core/isolated-element', async () => {
const { vi } = await import('vitest');
return {
createIsolatedElement: vi.fn(),
};
});
const createIsolatedElementMock = vi.mocked(createIsolatedElement);
const testApp = (container: Element) => {
const app = document.createElement('div');
app.textContent = 'App';
container.append(app);
};
const createCtx = () => new ContentScriptContext('test');
const fetch = vi.fn();
describe('createContentScriptUi', () => {
beforeEach(() => {
document.body.innerHTML = `
<div id="parent">
<p id="one">one</p>
<p id="two">two</p>
<p id="three"></p>
</div>
`;
window.fetch = fetch;
fetch.mockResolvedValue({ text: () => Promise.resolve('') });
createIsolatedElementMock.mockImplementation(async (config) => {
const parentElement = document.createElement(config.name);
const isolatedElement = document.createElement('html');
parentElement.append(isolatedElement);
return {
isolatedElement,
parentElement: parentElement,
shadow: mock<ShadowRoot>({
querySelector: (selector: string) => {
if (selector === 'html') return isolatedElement;
},
}),
};
});
});
describe('css', () => {
it('should load the CSS for the current entrypoint when cssInjectionMode=ui', async () => {
fetch.mockResolvedValue({ text: () => Promise.resolve('body {}') });
const ctx = new ContentScriptContext('test', {
matches: [],
cssInjectionMode: 'ui',
});
await createContentScriptUi(ctx, {
name: 'test',
type: 'inline',
mount: testApp,
});
expect(fetch).toBeCalledTimes(1);
expect(fetch).toBeCalledWith(
`chrome-extension://${browser.runtime.id}/content-scripts/${__ENTRYPOINT__}.css`,
);
expect(createIsolatedElementMock).toBeCalledTimes(1);
expect(createIsolatedElementMock).toBeCalledWith(
expect.objectContaining({
css: {
textContent: 'body {}',
},
}),
);
});
it('should still load the UI when fetch fails to load CSS file when cssInjectionMode=ui', async () => {
const error = Error('Test fetch error');
fetch.mockRejectedValue(error);
const ctx = new ContentScriptContext('test', {
matches: [],
cssInjectionMode: 'ui',
});
await createContentScriptUi(ctx, {
name: 'test',
type: 'inline',
mount: testApp,
});
expect(fetch).toBeCalledTimes(1);
expect(fetch).toBeCalledWith(
`chrome-extension://${browser.runtime.id}/content-scripts/${__ENTRYPOINT__}.css`,
);
expect(createIsolatedElementMock).toBeCalledTimes(1);
expect(createIsolatedElementMock).toBeCalledWith(
expect.objectContaining({
css: {
textContent: '',
},
}),
);
});
it.each(['manifest', 'manual'] as const)(
'should not fetch CSS when cssInjectionMode=%s',
async (cssInjectionMode) => {
const ctx = new ContentScriptContext('test', {
matches: [],
cssInjectionMode,
});
await createContentScriptUi(ctx, {
name: 'test',
type: 'inline',
mount: testApp,
});
expect(fetch).not.toBeCalled();
},
);
});
describe('mount', () => {
describe('append option', () => {
it.each([undefined, 'last' as const])(
'should append the element as the last child to the anchor when append=%s',
async (append) => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
anchor: '#parent',
append,
mount: testApp,
});
ui.mount();
expect(
document.querySelector('#parent > :nth-child(4)')?.tagName,
).toEqual('TEST-APP');
},
);
it('should append the element as the first child to the anchor when append=first', async () => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
anchor: '#parent',
append: 'first',
mount: testApp,
});
ui.mount();
expect(
document.querySelector('#parent > :nth-child(1)')?.tagName,
).toEqual('TEST-APP');
});
it('should append the element normally when append=first but there are no other children', async () => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
anchor: '#three',
append: 'first',
mount: testApp,
});
ui.mount();
expect(
document.querySelector('#three > :nth-child(1)')?.tagName,
).toEqual('TEST-APP');
});
it('should replace the anchor with the element when append=replace', async () => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
anchor: '#two',
append: 'replace',
mount: testApp,
});
ui.mount();
expect(
document.querySelector('#parent > :nth-child(2)')?.tagName,
).toEqual('TEST-APP');
});
it('should append the element after the anchor when append=before', async () => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
anchor: '#two',
append: 'before',
mount: testApp,
});
ui.mount();
expect(
document.querySelector('#parent > :nth-child(2)')?.tagName,
).toEqual('TEST-APP');
});
it('should append the element after the anchor when append=after', async () => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
anchor: '#two',
append: 'after',
mount: testApp,
});
ui.mount();
// Happy DOM doesn't work in this case, so we just make sure the element is added.
// expect(
// document.querySelector('#parent > :nth-child(3)')?.tagName,
// ).toEqual('TEST-APP');
expect(document.querySelector('test-app')).toBeDefined();
});
it('should apply a custom function', async () => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
anchor: '#three',
append: (anchor, ui) => anchor.replaceWith(ui),
mount: testApp,
});
ui.mount();
expect(
document.querySelector('#parent > :nth-child(3)')?.tagName,
).toEqual('TEST-APP');
});
});
it('should default the anchor to the body when unset', async () => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
mount: testApp,
});
ui.mount();
expect(document.querySelector('body > :nth-child(2)')?.tagName).toEqual(
'TEST-APP',
);
});
it.each(['#four', () => document.querySelector('#four')])(
"should throw an error if the anchor doesn't exist",
async (anchor) => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test',
type: 'inline',
anchor,
mount: testApp,
});
expect(ui.mount).toThrow(
'Failed to mount content script UI: could not find anchor element',
);
},
);
});
describe('remove', () => {
it("should not fail if the ui hasn't been mounted", async () => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
mount: testApp,
});
ui.remove();
});
it('should automatically remove the UI when the context is invalidated', async () => {
const ctx = createCtx();
const ui = await createContentScriptUi(ctx, {
name: 'test-app',
type: 'inline',
mount: testApp,
});
ui.mount();
expect(document.querySelector('test-app')).toBeDefined();
ctx.abort();
expect(document.querySelector('test-app')).toBeNull();
});
});
describe('type', () => {
it.each(['inline', 'overlay', 'modal'] as const)(
'should render type=%s',
async (type) => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type,
mount: testApp,
});
ui.mount();
expect(document.querySelector('test-app')).toBeDefined();
},
);
});
describe('anchor', () => {
it.each([
() => '#two',
() => () => '#two',
() => document.querySelector('#two'),
() => () => document.querySelector('#two'),
])('should render anchor=%s', async (getAnchor) => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
anchor: getAnchor(),
mount: testApp,
});
ui.mount();
expect(document.querySelector('test-app')).toBeDefined();
});
});
it('should forward isolateEvents into createIsolatedElement', async () => {
const isolateEvents = faker.helpers.arrayElement([
undefined,
true,
false,
['click', 'keydown'],
]);
await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
mount: testApp,
isolateEvents,
});
expect(createIsolatedElementMock).toBeCalledTimes(1);
expect(createIsolatedElementMock).toBeCalledWith(
expect.objectContaining({
isolateEvents,
}),
);
});
});
@@ -1,83 +0,0 @@
import { browser } from '~/browser';
import {
ContentScriptAnchoredOptions,
ContentScriptPositioningOptions,
applyContentScriptUiPosition,
mountContentScriptUiRoot,
} from '../utils/content-script-ui';
import { ContentScriptContext } from './content-script-context';
/**
* Utility for mounting a content script UI inside an iframe. Automatically removed from the DOM
* when the content script's context is invalidated.
*
* See https://wxt.dev/entrypoints/content-scripts.html#iframe for full documentation.
*
* @example
* export default defineContentScript({
* matches: ["*://*.google.com/*"],
*
* main(ctx) {
* const ui = await createContentScriptIframe(ctx, {
* page: "/content-script-overlay.html",
* type: "modal",
* })
* ui.mount();
* }
* })
*/
export function createContentScriptIframe(
ctx: ContentScriptContext,
options: ContentScriptIframeOptions,
): ContentScriptIframe {
const wrapper = document.createElement('div');
wrapper.classList.add('wxt-iframe-wrapper');
const iframe = document.createElement('iframe');
iframe.src = browser.runtime.getURL(options.page);
wrapper.appendChild(iframe);
const mount = () => {
applyContentScriptUiPosition(wrapper, iframe, options);
mountContentScriptUiRoot(wrapper, options);
};
const remove = () => {
wrapper.remove();
};
ctx.onInvalidated(remove);
return {
iframe,
wrapper,
mount,
remove,
};
}
export interface ContentScriptIframe {
/**
* The iframe added to the DOM.
*/
iframe: HTMLIFrameElement;
/**
* A wrapper div that assists in positioning.
*/
wrapper: HTMLDivElement;
/**
* Function that mounts or remounts the UI on the page.
*/
mount: () => void;
/**
* Function that removes the UI from the webpage.
*/
remove: () => void;
}
export type ContentScriptIframeOptions = ContentScriptPositioningOptions &
ContentScriptAnchoredOptions & {
/**
* The path to the unlisted HTML file to display in the iframe.
*/
page: import('wxt/browser').PublicPath;
};
@@ -1,183 +0,0 @@
import { createIsolatedElement } from '@webext-core/isolated-element';
import { browser } from '~/browser';
import { logger } from '~/sandbox/utils/logger';
import { ContentScriptContext } from './content-script-context';
import {
ContentScriptAnchoredOptions,
ContentScriptPositioningOptions,
applyContentScriptUiPosition,
mountContentScriptUiRoot,
} from '../utils/content-script-ui';
/**
* Utility for mounting content script UI's with isolated styles and controlled event bubbling.
* Automatically removed from the DOM when the content script's context is invalidated.
*
* See https://wxt.dev/guide/content-script-ui.html for full documentation.
*
* @example
* // entrypoints/example-ui.content/index.ts
* import "./style.css"
*
* export default defineContentScript({
* matches: ["*://*.google.com/*"],
* cssInjectionMode: "ui",
*
* async main(ctx) {
* const ui = await createContentScriptUi(ctx, {
* name: "example-overlay",
* type: "modal",
* mount(container) {
* const app = document.createElement("div");
* app.textContent = "Content Script UI";
* container.append(app);
* },
* })
* ui.mount();
* }
* })
*/
export async function createContentScriptUi<TApp>(
ctx: ContentScriptContext,
options: ContentScriptUiOptions<TApp>,
): Promise<ContentScriptUi<TApp>> {
const css = [options.css ?? ''];
if (ctx.options?.cssInjectionMode === 'ui') {
css.push(await loadCss());
}
const {
isolatedElement: uiContainer,
parentElement: shadowHost,
shadow,
} = await createIsolatedElement({
name: options.name,
css: {
textContent: css.join('\n').trim(),
},
mode: 'open',
isolateEvents: options.isolateEvents,
});
let mounted: TApp;
const mount = () => {
// Mount UI inside shadow root
mounted = options.mount(uiContainer);
// Add shadow root element to DOM
mountContentScriptUiRoot(shadowHost, options);
applyContentScriptUiPosition(
shadowHost,
shadow.querySelector('html'),
options,
);
};
const remove = () => {
// Detatch shadow root from DOM
shadowHost.remove();
// Cleanup mounted state
options.onRemove?.(mounted);
// Remove children from uiContainer
while (uiContainer.lastChild)
uiContainer.removeChild(uiContainer.lastChild);
};
ctx.onInvalidated(remove);
return {
shadow,
shadowHost,
uiContainer,
mount,
remove,
mounted: mounted!,
};
}
/**
* Load the CSS for the current entrypoint.
*/
async function loadCss(): Promise<string> {
const url = browser.runtime.getURL(`/content-scripts/${__ENTRYPOINT__}.css`);
try {
const res = await fetch(url);
const css = await res.text();
// Replace :root selectors with :host since we're in a shadow root
return css.replaceAll(':root', ':host');
} catch (err) {
logger.warn(
`Failed to load styles @ ${url}. Did you forget to import the stylesheet in your entrypoint?`,
err,
);
return '';
}
}
export interface ContentScriptUi<TApp> {
/**
* The `HTMLElement` hosting the shadow root used to isolate the UI's styles. This is the element
* that get's added to the DOM. This element's style is not isolated from the webpage.
*/
shadowHost: HTMLElement;
/**
* The container element inside the `ShadowRoot` whose styles are isolated. The UI is mounted
* inside this `HTMLElement`.
*/
uiContainer: HTMLElement;
/**
* The shadow root performing the isolation.
*/
shadow: ShadowRoot;
/**
* Custom data returned from the `options.mount` function.
*/
mounted: TApp;
/**
* Function that mounts or remounts the UI on the page.
*/
mount: () => void;
/**
* Function that removes the UI from the webpage.
*/
remove: () => void;
}
export type ContentScriptUiOptions<TApp> = ContentScriptPositioningOptions &
ContentScriptAnchoredOptions & {
/**
* The name of the custom component used to host the ShadowRoot. Must be kebab-case.
*/
name: string;
/**
* Callback executed when mounting the UI. This function should create and append the UI to the
* `container` element. It is called every time `ui.mount()` is called
*
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
*/
mount: (container: Element) => TApp;
/**
* Callback called when the UI is removed from the webpage. Use to cleanup your UI, like
* unmounting your vue or react apps.
*/
onRemove?: (mounted: TApp) => void;
/**
* Custom CSS text to apply to the UI. If your content script imports/generates CSS and you've
* set `cssInjectionMode: "ui"`, the imported CSS will be included automatically. You do not need
* to pass those styles in here. This is for any additional styles not in the imported CSS.
*
* See https://wxt.dev/guide/content-script-ui.html for more info.
*/
css?: string;
/**
* When enabled, `event.stopPropagation` will be called on events trying to bubble out of the
* shadow root.
*
* - Set to `true` to stop the propagation of a default set of events,
* `["keyup", "keydown", "keypress"]`
* - Set to an array of event names to stop the propagation of a custom list of events
*/
isolateEvents?: boolean | string[];
};
+1 -2
View File
@@ -1,3 +1,2 @@
export * from './content-script-context';
export * from './content-script-ui';
export * from './content-script-iframe';
export * from './ui';
@@ -0,0 +1,407 @@
/** @vitest-environment happy-dom */
import { describe, it, beforeEach, vi, expect } from 'vitest';
import { createIntegratedUi, createIframeUi, createShadowRootUi } from '..';
import { ContentScriptContext } from '../../content-script-context';
function appendTestApp(container: HTMLElement) {
container.innerHTML = '<app>Hello world</app>';
}
const fetch = vi.fn();
describe('Content Script UIs', () => {
let ctx: ContentScriptContext;
beforeEach(() => {
document.body.innerHTML = `
<div id="parent">
<p id="one">one</p>
<p id="two">two</p>
<p id="three"></p>
</div>
`;
window.fetch = fetch;
fetch.mockResolvedValue({ text: () => Promise.resolve('') });
ctx = new ContentScriptContext('test');
});
describe('type', () => {
describe('integrated', () => {
it('should add a wrapper and custom UI to the page', () => {
const ui = createIntegratedUi(ctx, {
position: 'inline',
onMount: appendTestApp,
});
ui.mount();
expect(
document.querySelector('div[data-wxt-integrated]'),
).not.toBeNull();
expect(document.querySelector('app')).not.toBeNull();
});
it('should allow customizing the wrapper tag', () => {
const ui = createIntegratedUi(ctx, {
position: 'inline',
tag: 'pre',
onMount: appendTestApp,
});
ui.mount();
expect(
document.querySelector('pre[data-wxt-integrated]'),
).not.toBeNull();
expect(document.querySelector('app')).not.toBeNull();
});
});
describe('iframe', () => {
it('should add a wrapper and iframe to the page', () => {
const ui = createIframeUi(ctx, {
page: '/page.html',
position: 'inline',
});
ui.mount();
expect(document.querySelector('div[data-wxt-iframe]')).toBeDefined();
expect(document.querySelector('iframe')).toBeDefined();
});
});
describe('shadow-root', () => {
it('should load a shadow root to the page', async () => {
const ui = await createShadowRootUi(ctx, {
position: 'inline',
name: 'test',
onMount(uiContainer) {
appendTestApp(uiContainer);
},
});
ui.mount();
expect(
document.querySelector('test[data-wxt-shadow-root]'),
).not.toBeNull();
expect(ui.shadow.querySelector('app')).not.toBeNull();
});
it.each([
['open', 'open'],
[undefined, 'open'],
['closed', 'closed'],
] as const)(
'should respect the shadow root mode (%s -> %s)',
async (input, expected) => {
const ui = await createShadowRootUi(ctx, {
position: 'inline',
name: 'test',
mode: input,
onMount: appendTestApp,
});
expect(ui.shadow.mode).toBe(expected);
},
);
});
});
describe('position', () => {
describe('inline', () => {
it('should wrap the UI in a simple div', () => {
const ui = createIframeUi(ctx, {
position: 'inline',
page: '/page.html',
});
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
`"<div data-wxt-iframe=""><iframe src="chrome-extension://test-extension-id/page.html"></iframe></div>"`,
);
});
});
describe('overlay', () => {
it('should wrap the UI in a positioned div when alignment=undefined', () => {
const ui = createIframeUi(ctx, {
position: 'overlay',
page: '/page.html',
});
ui.mount();
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; top: 0px; left: 0px;"></iframe></div>"`,
);
});
it('should wrap the UI in a positioned div when alignment=top-left', () => {
const ui = createIframeUi(ctx, {
position: 'overlay',
page: '/page.html',
alignment: 'top-left',
});
ui.mount();
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; top: 0px; left: 0px;"></iframe></div>"`,
);
});
it('should wrap the UI in a positioned div when alignment=top-right', () => {
const ui = createIframeUi(ctx, {
position: 'overlay',
page: '/page.html',
alignment: 'top-right',
});
ui.mount();
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; top: 0px; right: 0px;"></iframe></div>"`,
);
});
it('should wrap the UI in a positioned div when alignment=bottom-right', () => {
const ui = createIframeUi(ctx, {
position: 'overlay',
page: '/page.html',
alignment: 'bottom-right',
});
ui.mount();
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; bottom: 0px; right: 0px;"></iframe></div>"`,
);
});
it('should wrap the UI in a positioned div when alignment=bottom-left', () => {
const ui = createIframeUi(ctx, {
position: 'overlay',
page: '/page.html',
alignment: 'bottom-left',
});
ui.mount();
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; bottom: 0px; left: 0px;"></iframe></div>"`,
);
});
it('should respect the provided zIndex', () => {
const zIndex = 123;
const ui = createIframeUi(ctx, {
position: 'overlay',
page: '/page.html',
zIndex,
});
ui.mount();
expect(ui.wrapper.style.zIndex).toBe(String(zIndex));
});
});
describe('modal', () => {
it('should wrap the UI in a div with a fixed position', () => {
const ui = createIframeUi(ctx, {
position: 'modal',
page: '/page.html',
});
ui.mount();
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: fixed; top: 0px; bottom: 0px; left: 0px; right: 0px;"></iframe></div>"`,
);
});
it('should respect the provided zIndex', () => {
const zIndex = 123;
const ui = createIframeUi(ctx, {
position: 'modal',
page: '/page.html',
zIndex,
});
ui.mount();
expect(ui.wrapper.style.zIndex).toBe(String(zIndex));
});
});
});
describe('anchor', () => {
describe('undefined', () => {
it('should append the element to the body', () => {
const ui = createIntegratedUi(ctx, {
position: 'inline',
onMount: appendTestApp,
});
ui.mount();
expect(
document.querySelector('body > div[data-wxt-integrated]'),
).not.toBeNull();
});
});
describe('string', () => {
it('should append the element using the specified query selector', () => {
const ui = createIntegratedUi(ctx, {
position: 'inline',
onMount: appendTestApp,
anchor: '#parent',
});
ui.mount();
expect(
document.querySelector('#parent > div[data-wxt-integrated]'),
).not.toBeNull();
});
});
describe('Element', () => {
it('should append the element using the specified element', () => {
const ui = createIntegratedUi(ctx, {
position: 'inline',
onMount: appendTestApp,
anchor: document.getElementById('parent'),
});
ui.mount();
expect(
document.querySelector('#parent > div[data-wxt-integrated]'),
).not.toBeNull();
});
});
describe('function', () => {
it('should append the element using the specified function', () => {
const ui = createIntegratedUi(ctx, {
position: 'inline',
onMount: appendTestApp,
anchor: () => document.getElementById('parent'),
});
ui.mount();
expect(
document.querySelector('#parent > div[data-wxt-integrated]'),
).not.toBeNull();
});
});
it('should throw an error when the anchor does not exist', () => {
const ui = createIntegratedUi(ctx, {
position: 'inline',
onMount: appendTestApp,
anchor: () => document.getElementById('i-do-not-exist'),
});
expect(ui.mount).toThrow();
});
});
describe('append', () => {
describe.each([undefined, 'last'] as const)('%s', (append) => {
it('should append the element as the last child of the anchor', () => {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#parent',
append,
onMount: appendTestApp,
});
ui.mount();
expect(
document.querySelector(
'#parent > div[data-wxt-integrated]:last-child',
),
).not.toBeNull();
});
});
describe('first', () => {
it('should append the element as the last child of the anchor', () => {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#parent',
append: 'first',
onMount: appendTestApp,
});
ui.mount();
expect(
document.querySelector(
'#parent > div[data-wxt-integrated]:first-child',
),
).not.toBeNull();
});
});
describe('replace', () => {
it('should replace the the anchor', () => {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#parent',
append: 'replace',
onMount: appendTestApp,
});
ui.mount();
expect(
document.querySelector('body > div[data-wxt-integrated]'),
).not.toBeNull();
expect(document.querySelector('#parent')).toBeNull();
});
});
describe('before', () => {
it('should append the UI before the anchor', () => {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#one',
append: 'before',
onMount: appendTestApp,
});
ui.mount();
expect(
document.querySelector(
'#parent > div[data-wxt-integrated]:first-child',
),
).not.toBeNull();
});
});
describe.todo('after', () => {
it('should append the UI after the anchor', () => {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#three',
append: 'after',
onMount: appendTestApp,
});
ui.mount();
expect(
document.querySelector(
'#parent > div[data-wxt-integrated]:last-child',
),
).not.toBeNull();
});
});
describe('function', () => {
it('should append the UI using a function', () => {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#parent',
append: (anchor, ui) => {
anchor.replaceWith(ui);
},
onMount: appendTestApp,
});
ui.mount();
expect(
document.querySelector('body > div[data-wxt-integrated]'),
).not.toBeNull();
expect(document.querySelector('#parent')).toBeNull();
});
});
});
});
+250
View File
@@ -0,0 +1,250 @@
import { browser } from '~/browser';
import { ContentScriptContext } from '..';
import {
ContentScriptAnchoredOptions,
ContentScriptPositioningOptions,
IframeContentScriptUi,
IframeContentScriptUiOptions,
IntegratedContentScriptUi,
IntegratedContentScriptUiOptions,
ShadowRootContentScriptUi,
ShadowRootContentScriptUiOptions,
} from './types';
import { logger } from '~/sandbox/utils/logger';
import { createIsolatedElement } from '@webext-core/isolated-element';
export * from './types';
/**
* Create a content script UI without any isolation.
*
* @see https://wxt.dev/guide/content-script-ui.html#integrated
*/
export function createIntegratedUi<TMounted>(
ctx: ContentScriptContext,
options: IntegratedContentScriptUiOptions<TMounted>,
): IntegratedContentScriptUi<TMounted> {
const wrapper = document.createElement(options.tag || 'div');
wrapper.setAttribute('data-wxt-integrated', '');
let mounted: TMounted | undefined = undefined;
const mount = () => {
applyPosition(wrapper, undefined, options);
mountUi(wrapper, options);
mounted = options.onMount?.(wrapper);
};
const remove = () => {
options.onRemove?.(mounted);
wrapper.remove();
};
ctx.onInvalidated(remove);
return {
mounted,
wrapper,
mount,
remove,
};
}
/**
* Create a content script UI using an iframe.
*
* @see https://wxt.dev/guide/content-script-ui.html#iframe
*/
export function createIframeUi<TMounted>(
ctx: ContentScriptContext,
options: IframeContentScriptUiOptions<TMounted>,
): IframeContentScriptUi<TMounted> {
const wrapper = document.createElement('div');
wrapper.setAttribute('data-wxt-iframe', '');
const iframe = document.createElement('iframe');
iframe.src = browser.runtime.getURL(options.page);
wrapper.appendChild(iframe);
let mounted: TMounted | undefined = undefined;
const mount = () => {
applyPosition(wrapper, iframe, options);
mountUi(wrapper, options);
mounted = options.onMount?.(wrapper, iframe);
};
const remove = () => {
options.onRemove?.(mounted);
wrapper.remove();
};
ctx.onInvalidated(remove);
return {
mounted,
iframe,
wrapper,
mount,
remove,
};
}
/**
* Create a content script UI inside a [`ShadowRoot`](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot).
*
* > This function is async because it has to load the CSS via a network call.
*
* @see https://wxt.dev/guide/content-script-ui.html#shadowroot
*/
export async function createShadowRootUi<TMounted>(
ctx: ContentScriptContext,
options: ShadowRootContentScriptUiOptions<TMounted>,
): Promise<ShadowRootContentScriptUi<TMounted>> {
const css = [options.css ?? ''];
if (ctx.options?.cssInjectionMode === 'ui') {
const entryCss = await loadCss();
// Replace :root selectors with :host since we're in a shadow root
css.push(entryCss.replaceAll(':root', ':host'));
}
const {
isolatedElement: uiContainer,
parentElement: shadowHost,
shadow,
} = await createIsolatedElement({
name: options.name,
css: {
textContent: css.join('\n').trim(),
},
mode: options.mode ?? 'open',
isolateEvents: options.isolateEvents,
});
shadowHost.setAttribute('data-wxt-shadow-root', '');
let mounted: TMounted;
const mount = () => {
// Mount UI inside shadow root
mounted = options.onMount(uiContainer, shadow, shadowHost);
// Add shadow root element to DOM
mountUi(shadowHost, options);
applyPosition(shadowHost, shadow.querySelector('html'), options);
};
const remove = () => {
// Detatch shadow root from DOM
shadowHost.remove();
// Cleanup mounted state
options.onRemove?.(mounted);
// Remove children from uiContainer
while (uiContainer.lastChild)
uiContainer.removeChild(uiContainer.lastChild);
};
ctx.onInvalidated(remove);
return {
shadow,
shadowHost,
uiContainer,
mount,
remove,
mounted: mounted!,
};
}
function applyPosition(
root: HTMLElement,
positionedElement: HTMLElement | undefined | null,
options: ContentScriptPositioningOptions,
): void {
// No positioning for inline UIs
if (options.position === 'inline') return;
if (options.zIndex != null) root.style.zIndex = String(options.zIndex);
root.style.overflow = 'visible';
root.style.position = 'relative';
root.style.width = '0';
root.style.height = '0';
root.style.display = 'block';
if (positionedElement) {
if (options.position === 'overlay') {
positionedElement.style.position = 'absolute';
if (options.alignment?.startsWith('bottom-'))
positionedElement.style.bottom = '0';
else positionedElement.style.top = '0';
if (options.alignment?.endsWith('-right'))
positionedElement.style.right = '0';
else positionedElement.style.left = '0';
} else {
positionedElement.style.position = 'fixed';
positionedElement.style.top = '0';
positionedElement.style.bottom = '0';
positionedElement.style.left = '0';
positionedElement.style.right = '0';
}
}
}
function getAnchor(options: ContentScriptAnchoredOptions): Element | undefined {
if (options.anchor == null) return document.body;
let resolved =
typeof options.anchor === 'function' ? options.anchor() : options.anchor;
if (typeof resolved === 'string')
return document.querySelector<Element>(resolved) ?? undefined;
return resolved ?? undefined;
}
function mountUi(
root: HTMLElement,
options: ContentScriptAnchoredOptions,
): void {
const anchor = getAnchor(options);
if (anchor == null)
throw Error(
'Failed to mount content script UI: could not find anchor element',
);
switch (options.append) {
case undefined:
case 'last':
anchor.append(root);
break;
case 'first':
if (anchor.firstChild) {
anchor.insertBefore(root, anchor.firstChild);
} else {
anchor.append(root);
}
break;
case 'replace':
anchor.replaceWith(root);
break;
case 'after':
anchor.replaceWith(anchor, root);
break;
case 'before':
anchor.replaceWith(root, anchor);
break;
default:
options.append(anchor, root);
break;
}
}
/**
* Load the CSS for the current entrypoint.
*/
async function loadCss(): Promise<string> {
const url = browser.runtime.getURL(`/content-scripts/${__ENTRYPOINT__}.css`);
try {
const res = await fetch(url);
return await res.text();
} catch (err) {
logger.warn(
`Failed to load styles @ ${url}. Did you forget to import the stylesheet in your entrypoint?`,
err,
);
return '';
}
}
+217
View File
@@ -0,0 +1,217 @@
export interface IntegratedContentScriptUi<TMounted>
extends ContentScriptUi<TMounted> {
/**
* A wrapper div that assists in positioning.
*/
wrapper: HTMLElement;
}
export interface IframeContentScriptUi<TMounted>
extends ContentScriptUi<TMounted> {
/**
* The iframe added to the DOM.
*/
iframe: HTMLIFrameElement;
/**
* A wrapper div that assists in positioning.
*/
wrapper: HTMLDivElement;
}
export interface ShadowRootContentScriptUi<TMounted>
extends ContentScriptUi<TMounted> {
/**
* The `HTMLElement` hosting the shadow root used to isolate the UI's styles. This is the element
* that get's added to the DOM. This element's style is not isolated from the webpage.
*/
shadowHost: HTMLElement;
/**
* The container element inside the `ShadowRoot` whose styles are isolated. The UI is mounted
* inside this `HTMLElement`.
*/
uiContainer: HTMLElement;
/**
* The shadow root performing the isolation.
*/
shadow: ShadowRoot;
}
export interface ContentScriptUi<TMounted> {
/**
* Function that mounts or remounts the UI on the page.
*/
mount: () => void;
/**
* Function that removes the UI from the webpage.
*/
remove: () => void;
/**>
* Custom data returned from the `options.mount` function.
*/
mounted: TMounted | undefined;
}
export type ContentScriptUiOptions<TMounted> = ContentScriptPositioningOptions &
ContentScriptAnchoredOptions & {
/**
* Callback called before the UI is removed from the webpage. Use to cleanup your UI, like
* unmounting your Vue or React apps.
*/
onRemove?: (mounted: TMounted | undefined) => void;
};
export type IntegratedContentScriptUiOptions<TMounted> =
ContentScriptUiOptions<TMounted> & {
/**
* Tag used to create the wrapper element.
*
* @default "div"
*/
tag?: string;
/**
* Callback executed when mounting the UI. This function should create and append the UI to the
* `wrapper` element. It is called every time `ui.mount()` is called.
*
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
*/
onMount: (wrapper: HTMLElement) => TMounted;
};
export type IframeContentScriptUiOptions<TMounted> =
ContentScriptUiOptions<TMounted> & {
/**
* The path to the HTML page that will be shown in the iframe. This string is passed into
* `browser.runtime.getURL`.
*/
page: PublicPath;
/**
* Callback executed when mounting the UI. Use this function to customize the iframe or wrapper
* element's appearance. It is called every time `ui.mount()` is called.
*
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
*/
onMount?: (wrapper: HTMLElement, iframe: HTMLIFrameElement) => TMounted;
};
export type ShadowRootContentScriptUiOptions<TMounted> =
ContentScriptUiOptions<TMounted> & {
/**
* The name of the custom component used to host the ShadowRoot. Must be kebab-case.
*/
name: string;
/**
* Custom CSS text to apply to the UI. If your content script imports/generates CSS and you've
* set `cssInjectionMode: "ui"`, the imported CSS will be included automatically. You do not need
* to pass those styles in here. This is for any additional styles not in the imported CSS.
*/
css?: string;
/**
* ShadowRoot's mode.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/mode
* @default "open"
*/
mode?: 'open' | 'closed';
/**
* When enabled, `event.stopPropagation` will be called on events trying to bubble out of the
* shadow root.
*
* - Set to `true` to stop the propagation of a default set of events,
* `["keyup", "keydown", "keypress"]`
* - Set to an array of event names to stop the propagation of a custom list of events
*/
isolateEvents?: boolean | string[];
/**
* Callback executed when mounting the UI. This function should create and append the UI to the
* `uiContainer` element. It is called every time `ui.mount()` is called.
*
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
*/
onMount: (
uiContainer: HTMLElement,
shadow: ShadowRoot,
shadowHost: HTMLElement,
) => TMounted;
};
export type ContentScriptOverlayAlignment =
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right';
/**
* ![Visualization of different append modes](https://wxt.dev/content-script-ui-append.png)
*/
export type ContentScriptAppendMode =
| 'last'
| 'first'
| 'replace'
| 'before'
| 'after'
| ((anchor: Element, ui: Element) => void);
export interface ContentScriptInlinePositioningOptions {
position: 'inline';
}
export interface ContentScriptOverlayPositioningOptions {
position: 'overlay';
/**
* The `z-index` used on the `wrapper` element. Set to a positive number to show your UI over website
* content.
*/
zIndex?: number;
/**
* When using `type: "overlay"`, the mounted element is 0px by 0px in size. Alignment specifies
* which corner is aligned with that 0x0 pixel space.
*
* ![Visualization of alignment options](https://wxt.dev/content-script-ui-alignment.png)
*
* @default "top-left"
*/
alignment?: ContentScriptOverlayAlignment;
}
export interface ContentScriptModalPositioningOptions {
position: 'modal';
/**
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
* content.
*/
zIndex?: number;
}
/**
* Choose between `"inline"`, `"overlay"`, or `"modal" `types.
*
* ![Visualization of different types](https://wxt.dev/content-script-ui-type.png)
*/
export type ContentScriptPositioningOptions =
| ContentScriptInlinePositioningOptions
| ContentScriptOverlayPositioningOptions
| ContentScriptModalPositioningOptions;
export interface ContentScriptAnchoredOptions {
/**
* A CSS selector, element, or function that returns one of the two. Along with `append`, the
* `anchor` dictates where in the page the UI will be added.
*/
anchor?:
| string
| Element
| null
| undefined
| (() => string | Element | null | undefined);
/**
* In combination with `anchor`, decide how to add the UI to the DOM.
*
* - `"last"` (default) - Add the UI as the last child of the `anchor` element
* - `"first"` - Add the UI as the last child of the `anchor` element
* - `"replace"` - Replace the `anchor` element with the UI.
* - `"before"` - Add the UI as the sibling before the `anchor` element
* - `"after"` - Add the UI as the sibling after the `anchor` element
* - `(anchor, ui) => void` - Customizable function that let's you add the UI to the DOM
*/
append?: ContentScriptAppendMode | ((anchor: Element, ui: Element) => void);
}
-9
View File
@@ -4,12 +4,3 @@
* @module wxt/client
*/
export * from './content-scripts';
export {
ContentScriptOverlayAlignment,
ContentScriptAppendMode,
ContentScriptPositioningOptions,
ContentScriptAnchoredOptions,
ContentScriptInlinePositioningOptions,
ContentScriptOverlayPositioningOptions,
ContentScriptModalPositioningOptions,
} from './utils/content-script-ui';
-163
View File
@@ -1,163 +0,0 @@
export type ContentScriptOverlayAlignment =
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right';
/**
* ![Visualization of different append modes](https://wxt.dev/content-script-ui-append.png)
*/
export type ContentScriptAppendMode =
| 'last'
| 'first'
| 'replace'
| 'before'
| 'after'
| ((anchor: Element, ui: Element) => void);
export function mountContentScriptUiRoot(
root: HTMLElement,
options: ContentScriptAnchoredOptions,
): void {
const anchor = getAnchor(options);
if (anchor == null)
throw Error(
'Failed to mount content script UI: could not find anchor element',
);
switch (options.append) {
case undefined:
case 'last':
anchor.append(root);
break;
case 'first':
if (anchor.firstChild) {
anchor.insertBefore(root, anchor.firstChild);
} else {
anchor.append(root);
}
break;
case 'replace':
anchor.replaceWith(root);
break;
case 'after':
anchor.replaceWith(anchor, root);
break;
case 'before':
anchor.replaceWith(root, anchor);
break;
default:
options.append(anchor, root);
break;
}
}
export interface ContentScriptInlinePositioningOptions {
type: 'inline';
}
export interface ContentScriptOverlayPositioningOptions {
type: 'overlay';
/**
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
* content.
*/
zIndex?: number;
/**
* When using `type: "overlay"`, the mounted element is 0px by 0px in size. Alignment specifies
* which corner is aligned with that 0x0 pixel space.
*
* ![Visualization of alignment options](https://wxt.dev/content-script-ui-alignment.png)
*
* @default "top-left"
*/
alignment?: ContentScriptOverlayAlignment;
}
export interface ContentScriptModalPositioningOptions {
type: 'modal';
/**
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
* content.
*/
zIndex?: number;
}
/**
* Choose between `"inline"`, `"overlay"`, or `"modal" `types.
*
* ![Visualization of different types](https://wxt.dev/content-script-ui-type.png)
*/
export type ContentScriptPositioningOptions =
| ContentScriptInlinePositioningOptions
| ContentScriptOverlayPositioningOptions
| ContentScriptModalPositioningOptions;
export function applyContentScriptUiPosition(
root: HTMLElement,
positionedElement: HTMLElement | undefined | null,
options: ContentScriptPositioningOptions,
): void {
if (options.type !== 'inline') {
if (options.zIndex != null) root.style.zIndex = String(options.zIndex);
root.style.overflow = 'visible';
root.style.position = 'relative';
root.style.width = '0';
root.style.height = '0';
root.style.display = 'block';
if (positionedElement) {
if (options.type === 'overlay') {
positionedElement.style.position = 'absolute';
if (options.alignment?.startsWith('bottom-'))
positionedElement.style.bottom = '0';
else positionedElement.style.top = '0';
if (options.alignment?.endsWith('-right'))
positionedElement.style.right = '0';
else positionedElement.style.left = '0';
} else {
positionedElement.style.position = 'fixed';
positionedElement.style.top = '0';
positionedElement.style.bottom = '0';
positionedElement.style.left = '0';
positionedElement.style.right = '0';
}
}
}
}
function getAnchor(options: ContentScriptAnchoredOptions): Element | undefined {
if (options.anchor == null) return document.body;
let resolved =
typeof options.anchor === 'function' ? options.anchor() : options.anchor;
if (typeof resolved === 'string')
return document.querySelector<Element>(resolved) ?? undefined;
return resolved ?? undefined;
}
export interface ContentScriptAnchoredOptions {
/**
* A CSS selector, element, or function that returns one of the two. Along with `append`, the
* `anchor` dictates where in the page the UI will be added.
*/
anchor?:
| string
| Element
| null
| undefined
| (() => string | Element | null | undefined);
/**
* In combination with `anchor`, decide how to add the UI to the DOM.
*
* - `"last"` (default) - Add the UI as the last child of the `anchor` element
* - `"first"` - Add the UI as the last child of the `anchor` element
* - `"replace"` - Replace the `anchor` element with the UI.
* - `"before"` - Add the UI as the sibling before the `anchor` element
* - `"after"` - Add the UI as the sibling after the `anchor` element
* - `(anchor, ui) => void` - Customizable function that let's you add the UI to the DOM
*/
append?: ContentScriptAppendMode | ((anchor: Element, ui: Element) => void);
}
+5 -1
View File
@@ -12,7 +12,7 @@ import {
import * as wxtPlugins from './plugins';
import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
export async function craeteViteBuilder(
export async function createViteBuilder(
inlineConfig: InlineConfig,
userConfig: UserConfig,
wxtConfig: Omit<InternalConfig, 'builder'>,
@@ -40,6 +40,10 @@ export async function craeteViteBuilder(
config.build ??= {};
config.build.outDir = wxtConfig.outDir;
config.build.emptyOutDir = false;
// Disable minification for the dev command
if (config.build.minify == null && wxtConfig.command === 'serve') {
config.build.minify = false;
}
config.plugins ??= [];
config.plugins.push(
+3
View File
@@ -20,6 +20,7 @@ import {
getInternalConfig,
detectDevChanges,
rebuild,
findEntrypoints,
} from '~/core/utils/building';
import { createExtensionRunner } from '~/core/runners';
import { consola } from 'consola';
@@ -159,8 +160,10 @@ function createFileReloader(options: {
.join(pc.dim(', '));
// Rebuild entrypoints on change
const allEntrypoints = await findEntrypoints(config);
const { output: newOutput } = await rebuild(
config,
allEntrypoints,
// TODO: this excludes new entrypoints, so they're not built until the dev command is restarted
changes.rebuildGroups,
changes.cachedOutput,
+55 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { generateManifest } from '../manifest';
import { generateManifest, stripPathFromMatchPattern } from '../manifest';
import {
fakeArray,
fakeBackgroundEntrypoint,
@@ -646,6 +646,47 @@ describe('Manifest Utils', () => {
'content-scripts/one.css',
]);
});
it('should strip the path off the match pattern so the pattern is valid for `web_accessible_resources`', async () => {
const cs: ContentScriptEntrypoint = {
type: 'content-script',
name: 'one',
inputPath: 'entrypoints/one.content.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://play.google.com/books/*'],
cssInjectionMode: 'ui',
},
};
const styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/one.css',
};
const entrypoints = [cs];
const buildOutput: Omit<BuildOutput, 'manifest'> = {
publicAssets: [],
steps: [{ entrypoints: cs, chunks: [styles] }],
};
const config = fakeInternalConfig({
outDir,
command: 'build',
manifestVersion: 3,
});
const actual = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.web_accessible_resources).toEqual([
{
matches: ['*://play.google.com/*'],
resources: ['content-scripts/one.css'],
},
]);
});
});
});
@@ -855,7 +896,7 @@ describe('Manifest Utils', () => {
const reloadCommandName = 'wxt:reload-extension';
const reloadCommand = {
suggested_key: {
default: 'Ctrl+E',
default: 'Alt+R',
},
};
@@ -909,4 +950,16 @@ describe('Manifest Utils', () => {
});
});
});
describe('stripPathFromMatchPattern', () => {
it.each([
['<all_urls>', '<all_urls>'],
['*://play.google.com/books/*', '*://play.google.com/*'],
['*://*/*', '*://*/*'],
['https://github.com/wxt-dev/*', 'https://github.com/*'],
])('should convert "%s" to "%s"', (input, expected) => {
const actual = stripPathFromMatchPattern(input);
expect(actual).toEqual(expected);
});
});
});
+38 -2
View File
@@ -6,9 +6,11 @@ import {
} from '~/types';
import { getPublicFiles } from '~/core/utils/fs';
import fs from 'fs-extra';
import { dirname, resolve } from 'path';
import { dirname, resolve, extname } from 'node: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[],
@@ -25,7 +27,13 @@ export async function buildEntrypoints(
spinner.text = pc.dim(`[${i + 1}/${groups.length}]`) + ` ${groupNames}`;
steps.push(await config.builder.build(group));
}
const publicAssets = await copyPublicDirectory(config);
const publicAssets = (
await Promise.all([
copyPublicDirectory(config),
copyLocalesDirectory(config),
])
).flat();
return { publicAssets, steps };
}
@@ -51,3 +59,31 @@ 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',
};
}),
);
}
+38 -15
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.
@@ -113,30 +113,38 @@ declare module "wxt/browser" {
}
export interface WxtI18n extends I18n.Static {
{{ overrides }}
{{ browserOverrides }}
}
}
declare module "wxt/i18n" {
export interface WxtMessageSchema {
t: {
{{ translationTOverrides }}
};
tp: {
{{ translationTpOverrides }}
};
}
}
`;
let messages: Message[];
if (defaultLocale) {
const defaultLocalePath = path.resolve(
config.publicDir,
'_locales',
defaultLocale,
'messages.json',
);
const content = JSON.parse(await fs.readFile(defaultLocalePath, 'utf-8'));
messages = parseI18nMessages(content);
const [defaultLocalePath] = await glob(`${defaultLocale}.*`, {
cwd: config.localesDir,
absolute: true,
});
messages = await readMessagesFile(defaultLocalePath);
} else {
messages = parseI18nMessages({});
messages = PREDEFINED_MESSAGES;
}
const overrides = messages.map((message) => {
return ` /**
* ${message.description ?? 'No message description.'}
* ${message.entry.description ?? 'No message description.'}
*
* "${message.message}"
* "${message.entry.message}"
*/
getMessage(
messageName: "${message.name}",
@@ -146,7 +154,22 @@ declare module "wxt/browser" {
});
await writeFileIfDifferent(
filePath,
template.replace('{{ overrides }}', overrides.join('\n')),
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'),
),
);
return filePath;
@@ -12,7 +12,7 @@ import {
import path from 'node:path';
import { createFsCache } from '~/core/utils/cache';
import consola, { LogLevels } from 'consola';
import { craeteViteBuilder } from '~/core/builders/vite';
import { createViteBuilder } from '~/core/builders/vite';
import defu from 'defu';
import { NullablyRequired } from '../types';
@@ -73,6 +73,7 @@ export async function getInternalConfig(
mergedConfig.entrypointsDir ?? 'entrypoints',
);
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}`);
@@ -111,6 +112,7 @@ export async function getInternalConfig(
outBaseDir,
outDir,
publicDir,
localesDir,
root,
runnerConfig,
srcDir,
@@ -134,7 +136,7 @@ export async function getInternalConfig(
server,
};
const builder = await craeteViteBuilder(
const builder = await createViteBuilder(
inlineConfig,
userConfig,
finalConfig,
@@ -196,6 +198,7 @@ function mergeInlineConfig(
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,
+1 -1
View File
@@ -38,7 +38,7 @@ export async function internalBuild(
const entrypoints = await findEntrypoints(config);
config.logger.debug('Detected entrypoints:', entrypoints);
const groups = groupEntrypoints(entrypoints);
const { output } = await rebuild(config, groups, undefined);
const { output } = await rebuild(config, entrypoints, groups, undefined);
// Post-build
await printBuildSummary(
+13 -3
View File
@@ -1,6 +1,10 @@
import type { Manifest } from '~/browser';
import { BuildOutput, EntrypointGroup, InternalConfig } from '~/types';
import { findEntrypoints } from './find-entrypoints';
import {
BuildOutput,
Entrypoint,
EntrypointGroup,
InternalConfig,
} from '~/types';
import { generateTypesDir } from './generate-wxt-dir';
import { buildEntrypoints } from './build-entrypoints';
import { generateManifest, writeManifest } from '~/core/utils/manifest';
@@ -14,9 +18,16 @@ import { generateManifest, writeManifest } from '~/core/utils/manifest';
* 2. Build the `entrypointGroups` (and copies public files)
* 3. Generate the latest manifest for all entrypoints
* 4. Write the new manifest to the file system
*
* @param config Internal config containing all the project information.
* @param allEntrypoints List of entrypoints used to generate the types inside .wxt directory.
* @param entrypointGroups The list of entrypoint groups to build.
* @param existingOutput The previous output to combine the rebuild results into. An emptry array if
* this is the first build.
*/
export async function rebuild(
config: InternalConfig,
allEntrypoints: Entrypoint[],
entrypointGroups: EntrypointGroup[],
existingOutput: Omit<BuildOutput, 'manifest'> = {
steps: [],
@@ -27,7 +38,6 @@ export async function rebuild(
const spinner = ora(`Preparing...`).start();
// Update types directory with new files and types
const allEntrypoints = await findEntrypoints(config);
await generateTypesDir(allEntrypoints, config).catch((err) => {
config.logger.warn('Failed to update .wxt directory:', err);
// Throw the error if doing a regular build, don't for dev mode.
-52
View File
@@ -1,52 +0,0 @@
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,
}));
}
+18 -3
View File
@@ -65,7 +65,7 @@ export async function generateManifest(
'Extension version not found, defaulting to "0.0.0". Add a version to your `package.json` or `wxt.config.ts` file. For more details, see: https://wxt.dev/guide/manifest.html#version-and-version-name',
);
}
let version = config.manifest.version ?? simplifyVersion(versionName);
const version = config.manifest.version ?? simplifyVersion(versionName);
const baseManifest: Manifest.WebExtensionManifest = {
manifest_version: config.manifestVersion,
@@ -80,7 +80,7 @@ export async function generateManifest(
'wxt:reload-extension': {
description: 'Reload the extension during development',
suggested_key: {
default: 'Ctrl+E',
default: 'Alt+R',
},
},
};
@@ -503,7 +503,10 @@ export function getContentScriptCssWebAccessibleResources(
} else {
resources.push({
resources: [cssFile],
matches: script.options.matches,
matches: resolvePerBrowserOption(
script.options.matches,
config.browser,
).map((matchPattern) => stripPathFromMatchPattern(matchPattern)),
});
}
});
@@ -547,3 +550,15 @@ function addHostPermission(
if (manifest.host_permissions.includes(hostPermission)) return;
manifest.host_permissions.push(hostPermission);
}
/**
* - "<all_urls>" &rarr; "<all_urls>"
* - "*://play.google.com/books/*" &rarr; "*://play.google.com/*"
*/
export function stripPathFromMatchPattern(pattern: string) {
const protocolSepIndex = pattern.indexOf('://');
if (protocolSepIndex === -1) return pattern;
const startOfPath = pattern.indexOf('/', protocolSepIndex + 3);
return pattern.substring(0, startOfPath) + '/*';
}
+1
View File
@@ -211,6 +211,7 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
outBaseDir: fakeDir(),
outDir: fakeDir(),
publicDir: fakeDir(),
localesDir: fakeDir(),
root: fakeDir(),
runnerConfig: {
config: {},
+1
View File
@@ -12,6 +12,7 @@ export function getUnimportOptions(
imports: [
{ name: 'defineConfig', from: 'wxt' },
{ name: 'fakeBrowser', from: 'wxt/testing' },
{ name: 'i18n', from: 'wxt/i18n' },
],
presets: [
{ package: 'wxt/client' },
+165
View File
@@ -0,0 +1,165 @@
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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,21 @@
{
"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
@@ -0,0 +1,87 @@
[
{
"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
@@ -0,0 +1,29 @@
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
@@ -0,0 +1,75 @@
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
@@ -0,0 +1,21 @@
/**
* 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
@@ -0,0 +1,210 @@
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;
}
+6
View File
@@ -27,6 +27,12 @@ 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"
*/
+1
View File
@@ -18,6 +18,7 @@ export interface InternalConfig {
root: string;
srcDir: string;
publicDir: string;
localesDir: string;
wxtDir: string;
typesDir: string;
entrypointsDir: string;
+1 -1
View File
@@ -23,6 +23,6 @@
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"wxt": "^0.13.0"
"wxt": "^0.14.0"
}
}
+1 -1
View File
@@ -20,6 +20,6 @@
"devDependencies": {
"typescript": "^5.3.3",
"vite-plugin-solid": "^2.8.0",
"wxt": "^0.13.0"
"wxt": "^0.14.0"
}
}
+1 -1
View File
@@ -20,6 +20,6 @@
"svelte-check": "^3.6.2",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"wxt": "^0.13.0"
"wxt": "^0.14.0"
}
}
+1 -1
View File
@@ -16,6 +16,6 @@
},
"devDependencies": {
"typescript": "^5.3.3",
"wxt": "^0.13.0"
"wxt": "^0.14.0"
}
}
+1 -1
View File
@@ -21,6 +21,6 @@
"@vitejs/plugin-vue": "^5.0.1",
"typescript": "^5.3.3",
"vue-tsc": "^1.8.27",
"wxt": "^0.13.0"
"wxt": "^0.14.0"
}
}