Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c20478cde2 | |||
| ebd50079a7 | |||
| b17bd87865 | |||
| e8b471e23f | |||
| 32ffca4168 | |||
| e9c573d5a1 | |||
| ae5126d726 | |||
| 24a77ca9b5 | |||
| 174cbbc5eb | |||
| 607c70dd56 | |||
| 616d012326 | |||
| 75b6ca7172 | |||
| 1497b3099c | |||
| a329e24818 | |||
| 249cf63da8 | |||
| 70a496108a | |||
| 214889e052 | |||
| b6ab7a9567 | |||
| 1ceb9e2e80 | |||
| 15ea81514f | |||
| 21d4e6dede | |||
| 5343430c0f |
@@ -1,5 +1,31 @@
|
||||
# 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)
|
||||
|
||||
@@ -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,9 +2,9 @@ export default defineContentScript({
|
||||
matches: ['*://*.google.com/*'],
|
||||
|
||||
main(ctx) {
|
||||
const ui = createContentScriptIframe(ctx, {
|
||||
const ui = createIframeUi(ctx, {
|
||||
page: '/iframe-src.html',
|
||||
type: 'overlay',
|
||||
position: 'overlay',
|
||||
anchor: 'form[action="/search"]',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
@@ -6,12 +6,12 @@ export default defineContentScript({
|
||||
cssInjectionMode: 'ui',
|
||||
|
||||
async main(ctx) {
|
||||
const ui = await createContentScriptUi(ctx, {
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'demo-ui',
|
||||
type: 'inline',
|
||||
position: 'inline',
|
||||
append: 'before',
|
||||
anchor: 'form[role=search]',
|
||||
mount: (container) => {
|
||||
onMount: (container) => {
|
||||
const app = document.createElement('div');
|
||||
app.textContent = 'Custom content script UI';
|
||||
container.append(app);
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -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 |
@@ -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' },
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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",
|
||||
|
||||
@@ -20,8 +20,9 @@ describe('Auto Imports', () => {
|
||||
const InvalidMatchPattern: typeof import('wxt/sandbox')['InvalidMatchPattern']
|
||||
const MatchPattern: typeof import('wxt/sandbox')['MatchPattern']
|
||||
const browser: typeof import('wxt/browser')['browser']
|
||||
const createContentScriptIframe: typeof import('wxt/client')['createContentScriptIframe']
|
||||
const createContentScriptUi: typeof import('wxt/client')['createContentScriptUi']
|
||||
const createIframeUi: typeof import('wxt/client')['createIframeUi']
|
||||
const createIntegratedUi: typeof import('wxt/client')['createIntegratedUi']
|
||||
const createShadowRootUi: typeof import('wxt/client')['createShadowRootUi']
|
||||
const defineBackground: typeof import('wxt/sandbox')['defineBackground']
|
||||
const defineConfig: typeof import('wxt')['defineConfig']
|
||||
const defineContentScript: typeof import('wxt/sandbox')['defineContentScript']
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wxt",
|
||||
"type": "module",
|
||||
"version": "0.13.5",
|
||||
"version": "0.14.2-alpha1",
|
||||
"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": {
|
||||
|
||||
@@ -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==}
|
||||
|
||||
@@ -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,3 +1,2 @@
|
||||
export * from './content-script-context';
|
||||
export * from './content-script-ui';
|
||||
export * from './content-script-iframe';
|
||||
export * from './ui';
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { describe, it, beforeEach, vi, expect } from 'vitest';
|
||||
import { createIntegratedUi, createIframeUi, createShadowRootUi } from '..';
|
||||
import { ContentScriptContext } from '../../content-script-context';
|
||||
|
||||
function appendTestApp(container: HTMLElement) {
|
||||
container.innerHTML = '<app>Hello world</app>';
|
||||
}
|
||||
|
||||
const fetch = vi.fn();
|
||||
|
||||
describe('Content Script UIs', () => {
|
||||
let ctx: ContentScriptContext;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="parent">
|
||||
<p id="one">one</p>
|
||||
<p id="two">two</p>
|
||||
<p id="three"></p>
|
||||
</div>
|
||||
`;
|
||||
window.fetch = fetch;
|
||||
fetch.mockResolvedValue({ text: () => Promise.resolve('') });
|
||||
ctx = new ContentScriptContext('test');
|
||||
});
|
||||
|
||||
describe('type', () => {
|
||||
describe('integrated', () => {
|
||||
it('should add a wrapper and custom UI to the page', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
expect(document.querySelector('app')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should allow customizing the wrapper tag', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
tag: 'pre',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('pre[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
expect(document.querySelector('app')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('iframe', () => {
|
||||
it('should add a wrapper and iframe to the page', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
page: '/page.html',
|
||||
position: 'inline',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(document.querySelector('div[data-wxt-iframe]')).toBeDefined();
|
||||
expect(document.querySelector('iframe')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shadow-root', () => {
|
||||
it('should load a shadow root to the page', async () => {
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
position: 'inline',
|
||||
name: 'test',
|
||||
onMount(uiContainer) {
|
||||
appendTestApp(uiContainer);
|
||||
},
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('test[data-wxt-shadow-root]'),
|
||||
).not.toBeNull();
|
||||
expect(ui.shadow.querySelector('app')).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['open', 'open'],
|
||||
[undefined, 'open'],
|
||||
['closed', 'closed'],
|
||||
] as const)(
|
||||
'should respect the shadow root mode (%s -> %s)',
|
||||
async (input, expected) => {
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
position: 'inline',
|
||||
name: 'test',
|
||||
mode: input,
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
|
||||
expect(ui.shadow.mode).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('position', () => {
|
||||
describe('inline', () => {
|
||||
it('should wrap the UI in a simple div', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'inline',
|
||||
page: '/page.html',
|
||||
});
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe=""><iframe src="chrome-extension://test-extension-id/page.html"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('overlay', () => {
|
||||
it('should wrap the UI in a positioned div when alignment=undefined', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; top: 0px; left: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should wrap the UI in a positioned div when alignment=top-left', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
alignment: 'top-left',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; top: 0px; left: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should wrap the UI in a positioned div when alignment=top-right', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
alignment: 'top-right',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; top: 0px; right: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should wrap the UI in a positioned div when alignment=bottom-right', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
alignment: 'bottom-right',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; bottom: 0px; right: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should wrap the UI in a positioned div when alignment=bottom-left', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
alignment: 'bottom-left',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: absolute; bottom: 0px; left: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect the provided zIndex', () => {
|
||||
const zIndex = 123;
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'overlay',
|
||||
page: '/page.html',
|
||||
zIndex,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.style.zIndex).toBe(String(zIndex));
|
||||
});
|
||||
});
|
||||
|
||||
describe('modal', () => {
|
||||
it('should wrap the UI in a div with a fixed position', () => {
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'modal',
|
||||
page: '/page.html',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.outerHTML).toMatchInlineSnapshot(
|
||||
`"<div data-wxt-iframe="" style="overflow: visible; position: relative; width: 0px; height: 0px; display: block;"><iframe src="chrome-extension://test-extension-id/page.html" style="position: fixed; top: 0px; bottom: 0px; left: 0px; right: 0px;"></iframe></div>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect the provided zIndex', () => {
|
||||
const zIndex = 123;
|
||||
const ui = createIframeUi(ctx, {
|
||||
position: 'modal',
|
||||
page: '/page.html',
|
||||
zIndex,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(ui.wrapper.style.zIndex).toBe(String(zIndex));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('anchor', () => {
|
||||
describe('undefined', () => {
|
||||
it('should append the element to the body', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('body > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('string', () => {
|
||||
it('should append the element using the specified query selector', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
anchor: '#parent',
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Element', () => {
|
||||
it('should append the element using the specified element', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
anchor: document.getElementById('parent'),
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('function', () => {
|
||||
it('should append the element using the specified function', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
anchor: () => document.getElementById('parent'),
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error when the anchor does not exist', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
anchor: () => document.getElementById('i-do-not-exist'),
|
||||
});
|
||||
|
||||
expect(ui.mount).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('append', () => {
|
||||
describe.each([undefined, 'last'] as const)('%s', (append) => {
|
||||
it('should append the element as the last child of the anchor', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#parent',
|
||||
append,
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'#parent > div[data-wxt-integrated]:last-child',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('first', () => {
|
||||
it('should append the element as the last child of the anchor', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#parent',
|
||||
append: 'first',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'#parent > div[data-wxt-integrated]:first-child',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('replace', () => {
|
||||
it('should replace the the anchor', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#parent',
|
||||
append: 'replace',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('body > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
expect(document.querySelector('#parent')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('before', () => {
|
||||
it('should append the UI before the anchor', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#one',
|
||||
append: 'before',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'#parent > div[data-wxt-integrated]:first-child',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.todo('after', () => {
|
||||
it('should append the UI after the anchor', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#three',
|
||||
append: 'after',
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'#parent > div[data-wxt-integrated]:last-child',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('function', () => {
|
||||
it('should append the UI using a function', () => {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
anchor: '#parent',
|
||||
append: (anchor, ui) => {
|
||||
anchor.replaceWith(ui);
|
||||
},
|
||||
onMount: appendTestApp,
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
expect(
|
||||
document.querySelector('body > div[data-wxt-integrated]'),
|
||||
).not.toBeNull();
|
||||
expect(document.querySelector('#parent')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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 '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
export interface IntegratedContentScriptUi<TMounted>
|
||||
extends ContentScriptUi<TMounted> {
|
||||
/**
|
||||
* A wrapper div that assists in positioning.
|
||||
*/
|
||||
wrapper: HTMLElement;
|
||||
}
|
||||
|
||||
export interface IframeContentScriptUi<TMounted>
|
||||
extends ContentScriptUi<TMounted> {
|
||||
/**
|
||||
* The iframe added to the DOM.
|
||||
*/
|
||||
iframe: HTMLIFrameElement;
|
||||
/**
|
||||
* A wrapper div that assists in positioning.
|
||||
*/
|
||||
wrapper: HTMLDivElement;
|
||||
}
|
||||
|
||||
export interface ShadowRootContentScriptUi<TMounted>
|
||||
extends ContentScriptUi<TMounted> {
|
||||
/**
|
||||
* The `HTMLElement` hosting the shadow root used to isolate the UI's styles. This is the element
|
||||
* that get's added to the DOM. This element's style is not isolated from the webpage.
|
||||
*/
|
||||
shadowHost: HTMLElement;
|
||||
/**
|
||||
* The container element inside the `ShadowRoot` whose styles are isolated. The UI is mounted
|
||||
* inside this `HTMLElement`.
|
||||
*/
|
||||
uiContainer: HTMLElement;
|
||||
/**
|
||||
* The shadow root performing the isolation.
|
||||
*/
|
||||
shadow: ShadowRoot;
|
||||
}
|
||||
|
||||
export interface ContentScriptUi<TMounted> {
|
||||
/**
|
||||
* Function that mounts or remounts the UI on the page.
|
||||
*/
|
||||
mount: () => void;
|
||||
/**
|
||||
* Function that removes the UI from the webpage.
|
||||
*/
|
||||
remove: () => void;
|
||||
/**>
|
||||
* Custom data returned from the `options.mount` function.
|
||||
*/
|
||||
mounted: TMounted | undefined;
|
||||
}
|
||||
|
||||
export type ContentScriptUiOptions<TMounted> = ContentScriptPositioningOptions &
|
||||
ContentScriptAnchoredOptions & {
|
||||
/**
|
||||
* Callback called before the UI is removed from the webpage. Use to cleanup your UI, like
|
||||
* unmounting your Vue or React apps.
|
||||
*/
|
||||
onRemove?: (mounted: TMounted | undefined) => void;
|
||||
};
|
||||
|
||||
export type IntegratedContentScriptUiOptions<TMounted> =
|
||||
ContentScriptUiOptions<TMounted> & {
|
||||
/**
|
||||
* Tag used to create the wrapper element.
|
||||
*
|
||||
* @default "div"
|
||||
*/
|
||||
tag?: string;
|
||||
/**
|
||||
* Callback executed when mounting the UI. This function should create and append the UI to the
|
||||
* `wrapper` element. It is called every time `ui.mount()` is called.
|
||||
*
|
||||
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
|
||||
*/
|
||||
onMount: (wrapper: HTMLElement) => TMounted;
|
||||
};
|
||||
|
||||
export type IframeContentScriptUiOptions<TMounted> =
|
||||
ContentScriptUiOptions<TMounted> & {
|
||||
/**
|
||||
* The path to the HTML page that will be shown in the iframe. This string is passed into
|
||||
* `browser.runtime.getURL`.
|
||||
*/
|
||||
page: PublicPath;
|
||||
/**
|
||||
* Callback executed when mounting the UI. Use this function to customize the iframe or wrapper
|
||||
* element's appearance. It is called every time `ui.mount()` is called.
|
||||
*
|
||||
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
|
||||
*/
|
||||
onMount?: (wrapper: HTMLElement, iframe: HTMLIFrameElement) => TMounted;
|
||||
};
|
||||
|
||||
export type ShadowRootContentScriptUiOptions<TMounted> =
|
||||
ContentScriptUiOptions<TMounted> & {
|
||||
/**
|
||||
* The name of the custom component used to host the ShadowRoot. Must be kebab-case.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Custom CSS text to apply to the UI. If your content script imports/generates CSS and you've
|
||||
* set `cssInjectionMode: "ui"`, the imported CSS will be included automatically. You do not need
|
||||
* to pass those styles in here. This is for any additional styles not in the imported CSS.
|
||||
*/
|
||||
css?: string;
|
||||
/**
|
||||
* ShadowRoot's mode.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/mode
|
||||
* @default "open"
|
||||
*/
|
||||
mode?: 'open' | 'closed';
|
||||
/**
|
||||
* When enabled, `event.stopPropagation` will be called on events trying to bubble out of the
|
||||
* shadow root.
|
||||
*
|
||||
* - Set to `true` to stop the propagation of a default set of events,
|
||||
* `["keyup", "keydown", "keypress"]`
|
||||
* - Set to an array of event names to stop the propagation of a custom list of events
|
||||
*/
|
||||
isolateEvents?: boolean | string[];
|
||||
/**
|
||||
* Callback executed when mounting the UI. This function should create and append the UI to the
|
||||
* `uiContainer` element. It is called every time `ui.mount()` is called.
|
||||
*
|
||||
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
|
||||
*/
|
||||
onMount: (
|
||||
uiContainer: HTMLElement,
|
||||
shadow: ShadowRoot,
|
||||
shadowHost: HTMLElement,
|
||||
) => TMounted;
|
||||
};
|
||||
|
||||
export type ContentScriptOverlayAlignment =
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right';
|
||||
|
||||
/**
|
||||
* 
|
||||
*/
|
||||
export type ContentScriptAppendMode =
|
||||
| 'last'
|
||||
| 'first'
|
||||
| 'replace'
|
||||
| 'before'
|
||||
| 'after'
|
||||
| ((anchor: Element, ui: Element) => void);
|
||||
|
||||
export interface ContentScriptInlinePositioningOptions {
|
||||
position: 'inline';
|
||||
}
|
||||
|
||||
export interface ContentScriptOverlayPositioningOptions {
|
||||
position: 'overlay';
|
||||
/**
|
||||
* The `z-index` used on the `wrapper` element. Set to a positive number to show your UI over website
|
||||
* content.
|
||||
*/
|
||||
zIndex?: number;
|
||||
/**
|
||||
* When using `type: "overlay"`, the mounted element is 0px by 0px in size. Alignment specifies
|
||||
* which corner is aligned with that 0x0 pixel space.
|
||||
*
|
||||
* 
|
||||
*
|
||||
* @default "top-left"
|
||||
*/
|
||||
alignment?: ContentScriptOverlayAlignment;
|
||||
}
|
||||
|
||||
export interface ContentScriptModalPositioningOptions {
|
||||
position: 'modal';
|
||||
/**
|
||||
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
|
||||
* content.
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose between `"inline"`, `"overlay"`, or `"modal" `types.
|
||||
*
|
||||
* 
|
||||
*/
|
||||
export type ContentScriptPositioningOptions =
|
||||
| ContentScriptInlinePositioningOptions
|
||||
| ContentScriptOverlayPositioningOptions
|
||||
| ContentScriptModalPositioningOptions;
|
||||
|
||||
export interface ContentScriptAnchoredOptions {
|
||||
/**
|
||||
* A CSS selector, element, or function that returns one of the two. Along with `append`, the
|
||||
* `anchor` dictates where in the page the UI will be added.
|
||||
*/
|
||||
anchor?:
|
||||
| string
|
||||
| Element
|
||||
| null
|
||||
| undefined
|
||||
| (() => string | Element | null | undefined);
|
||||
/**
|
||||
* In combination with `anchor`, decide how to add the UI to the DOM.
|
||||
*
|
||||
* - `"last"` (default) - Add the UI as the last child of the `anchor` element
|
||||
* - `"first"` - Add the UI as the last child of the `anchor` element
|
||||
* - `"replace"` - Replace the `anchor` element with the UI.
|
||||
* - `"before"` - Add the UI as the sibling before the `anchor` element
|
||||
* - `"after"` - Add the UI as the sibling after the `anchor` element
|
||||
* - `(anchor, ui) => void` - Customizable function that let's you add the UI to the DOM
|
||||
*/
|
||||
append?: ContentScriptAppendMode | ((anchor: Element, ui: Element) => void);
|
||||
}
|
||||
@@ -4,12 +4,3 @@
|
||||
* @module wxt/client
|
||||
*/
|
||||
export * from './content-scripts';
|
||||
export {
|
||||
ContentScriptOverlayAlignment,
|
||||
ContentScriptAppendMode,
|
||||
ContentScriptPositioningOptions,
|
||||
ContentScriptAnchoredOptions,
|
||||
ContentScriptInlinePositioningOptions,
|
||||
ContentScriptOverlayPositioningOptions,
|
||||
ContentScriptModalPositioningOptions,
|
||||
} from './utils/content-script-ui';
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
export type ContentScriptOverlayAlignment =
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right';
|
||||
|
||||
/**
|
||||
* 
|
||||
*/
|
||||
export type ContentScriptAppendMode =
|
||||
| 'last'
|
||||
| 'first'
|
||||
| 'replace'
|
||||
| 'before'
|
||||
| 'after'
|
||||
| ((anchor: Element, ui: Element) => void);
|
||||
|
||||
export function mountContentScriptUiRoot(
|
||||
root: HTMLElement,
|
||||
options: ContentScriptAnchoredOptions,
|
||||
): void {
|
||||
const anchor = getAnchor(options);
|
||||
if (anchor == null)
|
||||
throw Error(
|
||||
'Failed to mount content script UI: could not find anchor element',
|
||||
);
|
||||
|
||||
switch (options.append) {
|
||||
case undefined:
|
||||
case 'last':
|
||||
anchor.append(root);
|
||||
break;
|
||||
case 'first':
|
||||
if (anchor.firstChild) {
|
||||
anchor.insertBefore(root, anchor.firstChild);
|
||||
} else {
|
||||
anchor.append(root);
|
||||
}
|
||||
break;
|
||||
case 'replace':
|
||||
anchor.replaceWith(root);
|
||||
break;
|
||||
case 'after':
|
||||
anchor.replaceWith(anchor, root);
|
||||
break;
|
||||
case 'before':
|
||||
anchor.replaceWith(root, anchor);
|
||||
break;
|
||||
default:
|
||||
options.append(anchor, root);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ContentScriptInlinePositioningOptions {
|
||||
type: 'inline';
|
||||
}
|
||||
|
||||
export interface ContentScriptOverlayPositioningOptions {
|
||||
type: 'overlay';
|
||||
/**
|
||||
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
|
||||
* content.
|
||||
*/
|
||||
zIndex?: number;
|
||||
/**
|
||||
* When using `type: "overlay"`, the mounted element is 0px by 0px in size. Alignment specifies
|
||||
* which corner is aligned with that 0x0 pixel space.
|
||||
*
|
||||
* 
|
||||
*
|
||||
* @default "top-left"
|
||||
*/
|
||||
alignment?: ContentScriptOverlayAlignment;
|
||||
}
|
||||
|
||||
export interface ContentScriptModalPositioningOptions {
|
||||
type: 'modal';
|
||||
/**
|
||||
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
|
||||
* content.
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose between `"inline"`, `"overlay"`, or `"modal" `types.
|
||||
*
|
||||
* 
|
||||
*/
|
||||
export type ContentScriptPositioningOptions =
|
||||
| ContentScriptInlinePositioningOptions
|
||||
| ContentScriptOverlayPositioningOptions
|
||||
| ContentScriptModalPositioningOptions;
|
||||
|
||||
export function applyContentScriptUiPosition(
|
||||
root: HTMLElement,
|
||||
positionedElement: HTMLElement | undefined | null,
|
||||
options: ContentScriptPositioningOptions,
|
||||
): void {
|
||||
if (options.type !== 'inline') {
|
||||
if (options.zIndex != null) root.style.zIndex = String(options.zIndex);
|
||||
|
||||
root.style.overflow = 'visible';
|
||||
root.style.position = 'relative';
|
||||
root.style.width = '0';
|
||||
root.style.height = '0';
|
||||
root.style.display = 'block';
|
||||
|
||||
if (positionedElement) {
|
||||
if (options.type === 'overlay') {
|
||||
positionedElement.style.position = 'absolute';
|
||||
if (options.alignment?.startsWith('bottom-'))
|
||||
positionedElement.style.bottom = '0';
|
||||
else positionedElement.style.top = '0';
|
||||
|
||||
if (options.alignment?.endsWith('-right'))
|
||||
positionedElement.style.right = '0';
|
||||
else positionedElement.style.left = '0';
|
||||
} else {
|
||||
positionedElement.style.position = 'fixed';
|
||||
positionedElement.style.top = '0';
|
||||
positionedElement.style.bottom = '0';
|
||||
positionedElement.style.left = '0';
|
||||
positionedElement.style.right = '0';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAnchor(options: ContentScriptAnchoredOptions): Element | undefined {
|
||||
if (options.anchor == null) return document.body;
|
||||
|
||||
let resolved =
|
||||
typeof options.anchor === 'function' ? options.anchor() : options.anchor;
|
||||
if (typeof resolved === 'string')
|
||||
return document.querySelector<Element>(resolved) ?? undefined;
|
||||
return resolved ?? undefined;
|
||||
}
|
||||
|
||||
export interface ContentScriptAnchoredOptions {
|
||||
/**
|
||||
* A CSS selector, element, or function that returns one of the two. Along with `append`, the
|
||||
* `anchor` dictates where in the page the UI will be added.
|
||||
*/
|
||||
anchor?:
|
||||
| string
|
||||
| Element
|
||||
| null
|
||||
| undefined
|
||||
| (() => string | Element | null | undefined);
|
||||
/**
|
||||
* In combination with `anchor`, decide how to add the UI to the DOM.
|
||||
*
|
||||
* - `"last"` (default) - Add the UI as the last child of the `anchor` element
|
||||
* - `"first"` - Add the UI as the last child of the `anchor` element
|
||||
* - `"replace"` - Replace the `anchor` element with the UI.
|
||||
* - `"before"` - Add the UI as the sibling before the `anchor` element
|
||||
* - `"after"` - Add the UI as the sibling after the `anchor` element
|
||||
* - `(anchor, ui) => void` - Customizable function that let's you add the UI to the DOM
|
||||
*/
|
||||
append?: ContentScriptAppendMode | ((anchor: Element, ui: Element) => void);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import * as wxtPlugins from './plugins';
|
||||
import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
|
||||
|
||||
export async function craeteViteBuilder(
|
||||
export async function createViteBuilder(
|
||||
inlineConfig: InlineConfig,
|
||||
userConfig: UserConfig,
|
||||
wxtConfig: Omit<InternalConfig, 'builder'>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -896,7 +896,7 @@ describe('Manifest Utils', () => {
|
||||
const reloadCommandName = 'wxt:reload-extension';
|
||||
const reloadCommand = {
|
||||
suggested_key: {
|
||||
default: 'Ctrl+E',
|
||||
default: 'Alt+R',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -211,6 +211,7 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
|
||||
outBaseDir: fakeDir(),
|
||||
outDir: fakeDir(),
|
||||
publicDir: fakeDir(),
|
||||
localesDir: fakeDir(),
|
||||
root: fakeDir(),
|
||||
runnerConfig: {
|
||||
config: {},
|
||||
|
||||
@@ -18,6 +18,7 @@ export function getUnimportOptions(
|
||||
{ package: 'wxt/browser' },
|
||||
{ package: 'wxt/sandbox' },
|
||||
{ package: 'wxt/storage' },
|
||||
{ package: 'wxt/i18n' },
|
||||
],
|
||||
warn: config.logger.warn,
|
||||
dirs: ['components', 'composables', 'hooks', 'utils'],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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 * from './client';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface InternalConfig {
|
||||
root: string;
|
||||
srcDir: string;
|
||||
publicDir: string;
|
||||
localesDir: string;
|
||||
wxtDir: string;
|
||||
typesDir: string;
|
||||
entrypointsDir: string;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,6 @@
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"vite-plugin-solid": "^2.8.0",
|
||||
"wxt": "^0.13.0"
|
||||
"wxt": "^0.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"wxt": "^0.13.0"
|
||||
"wxt": "^0.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||