Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6516142d2 | |||
| 460667e3c0 | |||
| 6ed61d0766 | |||
| 971daf2784 | |||
| 89d8ef9a50 | |||
| a329e24818 | |||
| 249cf63da8 | |||
| 70a496108a | |||
| 214889e052 | |||
| b6ab7a9567 | |||
| 1ceb9e2e80 | |||
| 15ea81514f | |||
| 21d4e6dede | |||
| 5343430c0f | |||
| 8c834835c4 | |||
| 9a2cc18f64 | |||
| 8683bd4b77 | |||
| dca52864bb | |||
| 0c5643f648 | |||
| b7cdf1597c | |||
| 2770974261 |
@@ -12,14 +12,22 @@ A clear and concise description of what the bug is.
|
||||
|
||||
### To Reproduce
|
||||
|
||||
Upload a ZIP or share a link to a repo representing the minimal reproduction. **_If you don't upload a minimal reproduction, you bug report will be closed._**
|
||||
Share minimal reproduction. Examples of a minimal reproduction:
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
- PR with a failing test case
|
||||
- ZIP file containing a minimal WXT project (be sure to include your lockfile!!)
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
> **_⚠️ If you don't upload a minimal reproduction, your issue will be closed until a reproduction is added._**
|
||||
>
|
||||
> Why? https://antfu.me/posts/why-reproductions-are-required
|
||||
|
||||
Steps to reproduce the bug using the reproduction:
|
||||
|
||||
1. Install dependencies: `pnpm i`
|
||||
2. Start dev mode: `pnpm dev`
|
||||
3. Click this...
|
||||
4. Do that...
|
||||
5. Etc...
|
||||
|
||||
### Expected behavior
|
||||
|
||||
|
||||
@@ -1,5 +1,90 @@
|
||||
# Changelog
|
||||
|
||||
## v0.14.2
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.1...v0.14.2)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Add `filterEntrypoints` option to speed up development ([#344](https://github.com/wxt-dev/wxt/pull/344))
|
||||
|
||||
### 🔥 Performance
|
||||
|
||||
- Only call `findEntrypoint` once per build ([#342](https://github.com/wxt-dev/wxt/pull/342))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Improve error message and document use of imported variables outside an entrypoint's `main` function ([#346](https://github.com/wxt-dev/wxt/pull/346))
|
||||
- Allow `browser.runtime.getURL` to include hashes and query params for HTML paths ([#350](https://github.com/wxt-dev/wxt/pull/350))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Fix typos and outdated ui function usage ([#347](https://github.com/wxt-dev/wxt/pull/347))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Update templates to `^0.14.0` ([70a4961](https://github.com/wxt-dev/wxt/commit/70a4961))
|
||||
- Fix typo in function name ([a329e24](https://github.com/wxt-dev/wxt/commit/a329e24))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Yuns ([@yunsii](http://github.com/yunsii))
|
||||
- Armin
|
||||
|
||||
## v0.14.1
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.0...v0.14.1)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Use `Alt+R`/`Opt+R` to reload extension during development ([b6ab7a9](https://github.com/wxt-dev/wxt/commit/b6ab7a9))
|
||||
|
||||
## v0.14.0
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.5...v0.14.0)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- ⚠️ Refactor content script UI functions and add helper for "integrated" UIs ([#333](https://github.com/wxt-dev/wxt/pull/333))
|
||||
|
||||
#### ⚠️ Breaking Changes
|
||||
|
||||
`createContentScriptUi` and `createContentScriptIframe`, and some of their options, have been renamed:
|
||||
|
||||
- `createContentScriptUi({ ... })` → `createShadowRootUi({ ... })`
|
||||
- `createContentScriptIframe({ ... })` → `createIframeUi({ ... })`
|
||||
- `type: "inline" | "overlay" | "modal"` has been changed to `position: "inline" | "overlay" | "modal"`
|
||||
- `onRemove` is now called ***before*** the UI is removed from the DOM, previously it was called after the UI was removed
|
||||
- `mount` option has been renamed to `onMount`, to better match the related option, `onRemove`.
|
||||
|
||||
## v0.13.5
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.4...v0.13.5)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Strip path from `web_accessible_resources[0].matches` ([#332](https://github.com/wxt-dev/wxt/pull/332))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Add section about customizing other browser options during development ([8683bd4](https://github.com/wxt-dev/wxt/commit/8683bd4))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Update bug report template ([9a2cc18](https://github.com/wxt-dev/wxt/commit/9a2cc18))
|
||||
|
||||
## v0.13.4
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.3...v0.13.4)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Disable minification during development ([b7cdf15](https://github.com/wxt-dev/wxt/commit/b7cdf15))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Use `const` instead of `let` ([2770974](https://github.com/wxt-dev/wxt/commit/2770974))
|
||||
|
||||
## v0.13.3
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.2...v0.13.3)
|
||||
|
||||
@@ -15,6 +15,10 @@ export default defineBackground(() => {
|
||||
browser.runtime.getURL('/');
|
||||
browser.runtime.getURL('/background.js');
|
||||
browser.runtime.getURL('/icon/128.png');
|
||||
browser.runtime.getURL('/example.html#hash');
|
||||
browser.runtime.getURL('/example.html?query=param');
|
||||
// @ts-expect-error: should only allow hashes/query params on HTML files
|
||||
browser.runtime.getURL('/icon-128.png?query=param');
|
||||
|
||||
// @ts-expect-error: should only accept known message names
|
||||
browser.i18n.getMessage('test');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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!
|
||||
|
||||
+210
-137
@@ -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,52 +70,55 @@ 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();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts [Svelete]
|
||||
```ts [Svelte]
|
||||
// entrypoints/example-ui.content/index.ts
|
||||
import App from './App.svelte';
|
||||
|
||||
export default defineContentScript({
|
||||
main(ctx) {
|
||||
// Create the UI container
|
||||
const container = document.createElement('div');
|
||||
|
||||
// Create the Svelte app inside the UI container
|
||||
const app = new App({
|
||||
target: ui,
|
||||
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,152 @@ 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 = createShadowRootUi(ctx, {
|
||||
name: 'example-ui',
|
||||
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]
|
||||
// 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 = createShadowRootUi(ctx, {
|
||||
name: 'example-ui',
|
||||
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 = createShadowRootUi(ctx, {
|
||||
name: 'example-ui',
|
||||
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 +380,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.
|
||||
|
||||
@@ -39,6 +39,8 @@ export default defineRunnerConfig({
|
||||
|
||||
:::
|
||||
|
||||
### Browser Binaries
|
||||
|
||||
`web-ext`'s browser discovery is very limitted. By default, it only guesses at where Chrome and Firefox are installed. If you've customized your install locations, you may need to tell `web-ext` where the binaries/executables are located using the [`binaries` option](/api/wxt/interfaces/ExtensionRunnerConfig#binaries). For other Chromium based browsers, like Edge or Opera, you'll need to explicitly list them in the `binaries` option as well, otherwise they will open in Chrome by default.
|
||||
|
||||
```ts
|
||||
@@ -58,6 +60,23 @@ export default defineRunnerConfig({
|
||||
When configuring browser binaries, it's helpful to put them in `~/web-ext.config.ts` instead of the project directory's `web-ext.config.ts` file. When placed in your home directory (`~/`), this config will be used by all WXT projects, so you only need to configure the binaries once.
|
||||
:::
|
||||
|
||||
### Other options
|
||||
|
||||
You can customize other options as well, like startup URLs, profiles, or additional command line arguments:
|
||||
|
||||
```ts
|
||||
// web-ext.config.ts
|
||||
import { defineRunnerConfig } from 'wxt';
|
||||
|
||||
export default defineRunnerConfig({
|
||||
startUrls: ['https://google.com', 'https://duckduckgo.com'],
|
||||
chromiumProfile: '/path/to/profile/to/use',
|
||||
chromiumArgs: ['--window-size=400x300'],
|
||||
});
|
||||
```
|
||||
|
||||
For a full list of options, see the [API Reference](/api/wxt/interfaces/ExtensionRunnerConfig).
|
||||
|
||||
## Reload the Extension
|
||||
|
||||
Normally, to manually reload an extension, you have to visit `chrome://extensions` and click the reload button for your extension.
|
||||
|
||||
@@ -86,3 +86,56 @@ export default defineContentScript({
|
||||
:::info
|
||||
For a full list of entrypoints and each of their options, see the [`/entrypoints` folder](/entrypoints/background) documentation.
|
||||
:::
|
||||
|
||||
### Side Effects
|
||||
|
||||
You cannot use imported variables outside the `main` function is JS entrypoints. This includes options, as shown below:
|
||||
|
||||
```ts
|
||||
// entrypoints/content.ts
|
||||
import { GOOGLE_MATCHES } from '~/utils/match-patterns';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: GOOGLE_MATCHES,
|
||||
main() {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```
|
||||
$ wxt build
|
||||
wxt build
|
||||
|
||||
WXT 0.14.1
|
||||
ℹ Building chrome-mv3 for production with Vite 5.0.5
|
||||
✖ Command failed after 360 ms
|
||||
|
||||
[8:55:54 AM] ERROR entrypoints/content.ts: Cannot use imported variable "GOOGLE_MATCHES" before main function. See https://wxt.dev/guide/entrypoints.html#side-effects
|
||||
```
|
||||
|
||||
This throws an error because WXT needs to import each entrypoint during the build process to extract its definition (containing the `match`, `run_at`, `include`/`exclude`, etc.) to render the `manifest.json` correctly. Before loading an entrypoint, a transformation is applied to remove all imports. This prevents imported modules (local or NPM) with side-effects from running during the build process, potentially throwing an error.
|
||||
|
||||
:::details Why?
|
||||
|
||||
When importing your entrypoint to get its definition, the file is imported in a **_node environement_**, and doesn't have access to the `window`, `chrome`, or `browser` globals a web extension ususally has access to. If WXT doesn't remove all the imports from the file, the imported modules could try and access one of these variables, throwing an error.
|
||||
|
||||
:::
|
||||
|
||||
:::warning
|
||||
See [`wxt-dev/wxt#336`](https://github.com/wxt-dev/wxt/issues/336) to track the status of this bug.
|
||||
:::
|
||||
|
||||
Usually, this error occurs when you try to extract options into a shared file or try to run code outside the `main` function. To fix the example from above, use litteral values when defining an entrypoint instead of importing them:
|
||||
|
||||
```ts
|
||||
import { GOOGLE_MATCHES } from '~/utils/match-patterns'; // [!code --]
|
||||
|
||||
export default defineContentScript({
|
||||
matches: GOOGLE_MATCHES, // [!code --]
|
||||
matches: ['*//*.google.com/*'], // [!code ++]
|
||||
main() {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -49,8 +49,10 @@ describe('TypeScript Project', () => {
|
||||
| "/options.html"
|
||||
| "/popup.html"
|
||||
| "/sandbox.html"
|
||||
type HtmlPublicPath = Extract<PublicPath, \`\${string}.html\`>
|
||||
export interface WxtRuntime extends Runtime.Static {
|
||||
getURL(path: PublicPath): string;
|
||||
getURL(path: \`\${HtmlPublicPath}\${string}\`): string;
|
||||
}
|
||||
}
|
||||
"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wxt",
|
||||
"type": "module",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.2",
|
||||
"description": "Next gen framework for developing web extensions",
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
|
||||
+25
@@ -20,6 +20,13 @@ cli
|
||||
.option('-c, --config <file>', 'use specified config file')
|
||||
.option('-m, --mode <mode>', 'set env mode')
|
||||
.option('-b, --browser <browser>', 'specify a browser')
|
||||
.option(
|
||||
'-e, --filter-entrypoint <entrypoint>',
|
||||
'only build specific entrypoints',
|
||||
{
|
||||
type: [],
|
||||
},
|
||||
)
|
||||
.option('--mv3', 'target manifest v3')
|
||||
.option('--mv2', 'target manifest v2')
|
||||
.action(
|
||||
@@ -31,6 +38,7 @@ cli
|
||||
manifestVersion: flags.mv3 ? 3 : flags.mv2 ? 2 : undefined,
|
||||
configFile: flags.config,
|
||||
debug: flags.debug,
|
||||
filterEntrypoints: getArrayFromFlags(flags, 'filterEntrypoint'),
|
||||
});
|
||||
await server.start();
|
||||
return { isOngoing: true };
|
||||
@@ -43,6 +51,13 @@ cli
|
||||
.option('-c, --config <file>', 'use specified config file')
|
||||
.option('-m, --mode <mode>', 'set env mode')
|
||||
.option('-b, --browser <browser>', 'specify a browser')
|
||||
.option(
|
||||
'-e, --filter-entrypoint <entrypoint>',
|
||||
'only build specific entrypoints',
|
||||
{
|
||||
type: [],
|
||||
},
|
||||
)
|
||||
.option('--mv3', 'target manifest v3')
|
||||
.option('--mv2', 'target manifest v2')
|
||||
.option('--analyze', 'visualize extension bundle')
|
||||
@@ -58,6 +73,7 @@ cli
|
||||
analysis: {
|
||||
enabled: flags.analyze,
|
||||
},
|
||||
filterEntrypoints: getArrayFromFlags(flags, 'filterEntrypoint'),
|
||||
});
|
||||
}),
|
||||
);
|
||||
@@ -165,3 +181,12 @@ function wrapAction(
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Array flags, when not passed, are either `undefined` or `[undefined]`. This function filters out
|
||||
* the
|
||||
*/
|
||||
function getArrayFromFlags<T>(flags: any, name: string): T[] | undefined {
|
||||
const array = [flags[name]].flat() as Array<T | undefined>;
|
||||
return array.filter((item) => item != null) as T[];
|
||||
}
|
||||
|
||||
@@ -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'>,
|
||||
@@ -40,6 +40,10 @@ export async function craeteViteBuilder(
|
||||
config.build ??= {};
|
||||
config.build.outDir = wxtConfig.outDir;
|
||||
config.build.emptyOutDir = false;
|
||||
// Disable minification for the dev command
|
||||
if (config.build.minify == null && wxtConfig.command === 'serve') {
|
||||
config.build.minify = false;
|
||||
}
|
||||
|
||||
config.plugins ??= [];
|
||||
config.plugins.push(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('Entrypoint Utils', () => {
|
||||
name,
|
||||
outputDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
|
||||
const actual = getEntrypointOutputFile(entrypoint, ext);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateManifest } from '../manifest';
|
||||
import { generateManifest, stripPathFromMatchPattern } from '../manifest';
|
||||
import {
|
||||
fakeArray,
|
||||
fakeBackgroundEntrypoint,
|
||||
@@ -339,6 +339,7 @@ describe('Manifest Utils', () => {
|
||||
options: {
|
||||
matches: ['*://google.com/*'],
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
const cs1Styles: OutputAsset = {
|
||||
type: 'asset',
|
||||
@@ -353,6 +354,7 @@ describe('Manifest Utils', () => {
|
||||
matches: ['*://google.com/*'],
|
||||
runAt: 'document_end',
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
const cs2Styles: OutputAsset = {
|
||||
type: 'asset',
|
||||
@@ -367,6 +369,7 @@ describe('Manifest Utils', () => {
|
||||
matches: ['*://google.com/*'],
|
||||
runAt: 'document_end',
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
const cs3Styles: OutputAsset = {
|
||||
type: 'asset',
|
||||
@@ -381,6 +384,7 @@ describe('Manifest Utils', () => {
|
||||
matches: ['*://duckduckgo.com/*'],
|
||||
runAt: 'document_end',
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
const cs4Styles: OutputAsset = {
|
||||
type: 'asset',
|
||||
@@ -395,6 +399,7 @@ describe('Manifest Utils', () => {
|
||||
matches: ['*://google.com/*'],
|
||||
world: 'MAIN',
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
const cs5Styles: OutputAsset = {
|
||||
type: 'asset',
|
||||
@@ -454,6 +459,7 @@ describe('Manifest Utils', () => {
|
||||
options: {
|
||||
matches: ['*://google.com/*'],
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
const generatedContentScript = {
|
||||
matches: ['*://google.com/*'],
|
||||
@@ -493,6 +499,7 @@ describe('Manifest Utils', () => {
|
||||
matches: ['*://google.com/*'],
|
||||
cssInjectionMode,
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
const styles: OutputAsset = {
|
||||
type: 'asset',
|
||||
@@ -537,6 +544,7 @@ describe('Manifest Utils', () => {
|
||||
matches: ['*://google.com/*'],
|
||||
cssInjectionMode,
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
const styles: OutputAsset = {
|
||||
type: 'asset',
|
||||
@@ -578,6 +586,7 @@ describe('Manifest Utils', () => {
|
||||
matches: ['*://google.com/*'],
|
||||
cssInjectionMode: 'ui',
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
const styles: OutputAsset = {
|
||||
type: 'asset',
|
||||
@@ -619,6 +628,7 @@ describe('Manifest Utils', () => {
|
||||
matches: ['*://google.com/*'],
|
||||
cssInjectionMode: 'ui',
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
const styles: OutputAsset = {
|
||||
type: 'asset',
|
||||
@@ -646,6 +656,48 @@ describe('Manifest Utils', () => {
|
||||
'content-scripts/one.css',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should strip the path off the match pattern so the pattern is valid for `web_accessible_resources`', async () => {
|
||||
const cs: ContentScriptEntrypoint = {
|
||||
type: 'content-script',
|
||||
name: 'one',
|
||||
inputPath: 'entrypoints/one.content.ts',
|
||||
outputDir: contentScriptOutDir,
|
||||
options: {
|
||||
matches: ['*://play.google.com/books/*'],
|
||||
cssInjectionMode: 'ui',
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
const styles: OutputAsset = {
|
||||
type: 'asset',
|
||||
fileName: 'content-scripts/one.css',
|
||||
};
|
||||
|
||||
const entrypoints = [cs];
|
||||
const buildOutput: Omit<BuildOutput, 'manifest'> = {
|
||||
publicAssets: [],
|
||||
steps: [{ entrypoints: cs, chunks: [styles] }],
|
||||
};
|
||||
const config = fakeInternalConfig({
|
||||
outDir,
|
||||
command: 'build',
|
||||
manifestVersion: 3,
|
||||
});
|
||||
|
||||
const actual = await generateManifest(
|
||||
entrypoints,
|
||||
buildOutput,
|
||||
config,
|
||||
);
|
||||
|
||||
expect(actual.web_accessible_resources).toEqual([
|
||||
{
|
||||
matches: ['*://play.google.com/*'],
|
||||
resources: ['content-scripts/one.css'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -660,6 +712,7 @@ describe('Manifest Utils', () => {
|
||||
matches: ['*://google.com/*'],
|
||||
cssInjectionMode: 'ui',
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
const styles: OutputAsset = {
|
||||
type: 'asset',
|
||||
@@ -703,6 +756,7 @@ describe('Manifest Utils', () => {
|
||||
matches: ['*://google.com/*'],
|
||||
cssInjectionMode: 'ui',
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
const styles: OutputAsset = {
|
||||
type: 'asset',
|
||||
@@ -855,7 +909,7 @@ describe('Manifest Utils', () => {
|
||||
const reloadCommandName = 'wxt:reload-extension';
|
||||
const reloadCommand = {
|
||||
suggested_key: {
|
||||
default: 'Ctrl+E',
|
||||
default: 'Alt+R',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -909,4 +963,16 @@ describe('Manifest Utils', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripPathFromMatchPattern', () => {
|
||||
it.each([
|
||||
['<all_urls>', '<all_urls>'],
|
||||
['*://play.google.com/books/*', '*://play.google.com/*'],
|
||||
['*://*/*', '*://*/*'],
|
||||
['https://github.com/wxt-dev/*', 'https://github.com/*'],
|
||||
])('should convert "%s" to "%s"', (input, expected) => {
|
||||
const actual = stripPathFromMatchPattern(input);
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineContentScript } from '~/sandbox';
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: [faker.string.nanoid()],
|
||||
main() {},
|
||||
});
|
||||
@@ -53,6 +53,7 @@ describe('findEntrypoints', () => {
|
||||
defaultIcon: { '16': '/icon/16.png' },
|
||||
defaultTitle: 'Default Title',
|
||||
},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -72,6 +73,7 @@ describe('findEntrypoints', () => {
|
||||
options: {
|
||||
defaultTitle: 'Title',
|
||||
},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
])(
|
||||
@@ -103,6 +105,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'options.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -123,6 +126,7 @@ describe('findEntrypoints', () => {
|
||||
options: {
|
||||
openInTab: true,
|
||||
},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
])(
|
||||
@@ -146,6 +150,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'content',
|
||||
inputPath: resolve(config.entrypointsDir, 'content.ts'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -155,6 +160,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'overlay',
|
||||
inputPath: resolve(config.entrypointsDir, 'overlay.content.ts'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -164,6 +170,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'content',
|
||||
inputPath: resolve(config.entrypointsDir, 'content/index.ts'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -173,6 +180,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'overlay',
|
||||
inputPath: resolve(config.entrypointsDir, 'overlay.content/index.ts'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -182,6 +190,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'overlay',
|
||||
inputPath: resolve(config.entrypointsDir, 'overlay.content.tsx'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
])(
|
||||
@@ -212,6 +221,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'background',
|
||||
inputPath: resolve(config.entrypointsDir, 'background.ts'),
|
||||
outputDir: config.outDir,
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -221,6 +231,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'background',
|
||||
inputPath: resolve(config.entrypointsDir, 'background/index.ts'),
|
||||
outputDir: config.outDir,
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
])(
|
||||
@@ -258,6 +269,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'background',
|
||||
options: {},
|
||||
outputDir: config.outDir,
|
||||
skipped: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -278,6 +290,7 @@ describe('findEntrypoints', () => {
|
||||
name: 'injected',
|
||||
inputPath: resolve(config.entrypointsDir, path),
|
||||
outputDir: config.outDir,
|
||||
skipped: false,
|
||||
};
|
||||
const options: GenericEntrypoint['options'] = {};
|
||||
globMock.mockResolvedValueOnce([path]);
|
||||
@@ -304,6 +317,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'sandbox.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -314,6 +328,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'sandbox/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -324,6 +339,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'named.sandbox.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -334,6 +350,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'named.sandbox/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -346,6 +363,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'bookmarks.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -356,6 +374,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'bookmarks/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -368,6 +387,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'history.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -378,6 +398,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'history/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -390,6 +411,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'newtab.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -400,6 +422,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'newtab/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -412,6 +435,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'sidepanel.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -422,6 +446,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'sidepanel/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -432,6 +457,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'named.sidepanel.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -442,6 +468,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'named.sidepanel/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -454,6 +481,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'devtools.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -464,6 +492,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'devtools/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -476,6 +505,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'onboarding.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -486,6 +516,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'onboarding/index.html'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -498,6 +529,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'iframe.scss'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -508,6 +540,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'iframe.css'),
|
||||
outputDir: config.outDir,
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -520,6 +553,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'content.css'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -530,6 +564,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'overlay.content.css'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -540,6 +575,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'content/index.css'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -550,6 +586,7 @@ describe('findEntrypoints', () => {
|
||||
inputPath: resolve(config.entrypointsDir, 'overlay.content/index.css'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
options: {},
|
||||
skipped: false,
|
||||
},
|
||||
],
|
||||
])('should find entrypoint for %s', async (path, expected) => {
|
||||
@@ -737,4 +774,29 @@ describe('findEntrypoints', () => {
|
||||
expect(entrypoints).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterEntrypoints option', () => {
|
||||
it('should control entrypoints accessible', async () => {
|
||||
globMock.mockResolvedValue([
|
||||
'options/index.html',
|
||||
'popup/index.html',
|
||||
'ui.content/index.ts',
|
||||
'injected.content/index.ts',
|
||||
]);
|
||||
importEntrypointFileMock.mockResolvedValue({});
|
||||
const filterEntrypoints = ['popup', 'ui'];
|
||||
const config = fakeInternalConfig({
|
||||
root: '/',
|
||||
entrypointsDir: resolve('/src/entrypoints'),
|
||||
outDir: resolve('.output'),
|
||||
command: 'build',
|
||||
filterEntrypoints: new Set(filterEntrypoints),
|
||||
});
|
||||
|
||||
const entrypoints = await findEntrypoints(config);
|
||||
const names = entrypoints.map((item) => item.name);
|
||||
expect(names).toHaveLength(2);
|
||||
expect(names).toEqual(filterEntrypoints);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ const background: Entrypoint = {
|
||||
inputPath: '/background.ts',
|
||||
outputDir: '/.output/background',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
const contentScript: Entrypoint = {
|
||||
type: 'content-script',
|
||||
@@ -17,6 +18,7 @@ const contentScript: Entrypoint = {
|
||||
options: {
|
||||
matches: ['<all_urls>'],
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
const unlistedScript: Entrypoint = {
|
||||
type: 'unlisted-script',
|
||||
@@ -24,6 +26,7 @@ const unlistedScript: Entrypoint = {
|
||||
inputPath: '/injected.ts',
|
||||
outputDir: '/.output/injected',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
const popup: Entrypoint = {
|
||||
type: 'popup',
|
||||
@@ -31,6 +34,7 @@ const popup: Entrypoint = {
|
||||
inputPath: '/popup.html',
|
||||
outputDir: '/.output/popup',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
const unlistedPage: Entrypoint = {
|
||||
type: 'unlisted-page',
|
||||
@@ -38,6 +42,7 @@ const unlistedPage: Entrypoint = {
|
||||
inputPath: '/onboarding.html',
|
||||
outputDir: '/.output/onboarding',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
const options: Entrypoint = {
|
||||
type: 'options',
|
||||
@@ -45,6 +50,7 @@ const options: Entrypoint = {
|
||||
inputPath: '/options.html',
|
||||
outputDir: '/.output/options',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
const sandbox1: Entrypoint = {
|
||||
type: 'sandbox',
|
||||
@@ -52,6 +58,7 @@ const sandbox1: Entrypoint = {
|
||||
inputPath: '/sandbox1.html',
|
||||
outputDir: '/.output/sandbox1',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
const sandbox2: Entrypoint = {
|
||||
type: 'sandbox',
|
||||
@@ -59,6 +66,7 @@ const sandbox2: Entrypoint = {
|
||||
inputPath: '/sandbox2.html',
|
||||
outputDir: '/.output/sandbox2',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
const unlistedStyle: Entrypoint = {
|
||||
type: 'unlisted-style',
|
||||
@@ -66,6 +74,7 @@ const unlistedStyle: Entrypoint = {
|
||||
inputPath: '/injected.scss',
|
||||
outputDir: '/.output',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
const contentScriptStyle: Entrypoint = {
|
||||
type: 'content-script-style',
|
||||
@@ -73,6 +82,7 @@ const contentScriptStyle: Entrypoint = {
|
||||
inputPath: '/overlay.content.scss',
|
||||
outputDir: '/.output/content-scripts',
|
||||
options: {},
|
||||
skipped: false,
|
||||
};
|
||||
|
||||
describe('groupEntrypoints', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { importEntrypointFile } from '~/core/utils/building';
|
||||
import { fakeInternalConfig } from '~/core/utils/testing/fake-objects';
|
||||
import { resolve } from 'node:path';
|
||||
import { unnormalizePath } from '../../paths';
|
||||
|
||||
const entrypointPath = (filename: string) =>
|
||||
resolve('src/core/utils/__tests__/test-entrypoints', filename);
|
||||
@@ -38,4 +39,15 @@ describe('importEntrypointFile', () => {
|
||||
|
||||
expect(actual).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw a custom error message when an imported variable is used before main', async () => {
|
||||
const filePath = unnormalizePath(
|
||||
'../src/core/utils/__tests__/test-entrypoints/imported-option.ts',
|
||||
);
|
||||
await expect(() =>
|
||||
importEntrypointFile(entrypointPath('imported-option.ts'), config),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: ${filePath}: Cannot use imported variable "faker" outside the main function. See https://wxt.dev/guide/entrypoints.html#side-effects]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '~/core/utils/entrypoints';
|
||||
import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '~/core/utils/constants';
|
||||
import { CSS_EXTENSIONS_PATTERN } from '~/core/utils/paths';
|
||||
import pc from 'picocolors';
|
||||
|
||||
/**
|
||||
* Return entrypoints and their configuration by looking through the project's files.
|
||||
@@ -48,7 +49,14 @@ export async function findEntrypoints(
|
||||
);
|
||||
if (matchingGlob) {
|
||||
const type = PATH_GLOB_TO_TYPE_MAP[matchingGlob];
|
||||
results.push({ name, inputPath, type });
|
||||
results.push({
|
||||
name,
|
||||
inputPath,
|
||||
type,
|
||||
skipped:
|
||||
config.filterEntrypoints != null &&
|
||||
!config.filterEntrypoints.has(name),
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}, []);
|
||||
@@ -106,11 +114,22 @@ export async function findEntrypoints(
|
||||
inputPath: VIRTUAL_NOOP_BACKGROUND_MODULE_ID,
|
||||
name: 'background',
|
||||
type: 'background',
|
||||
skipped: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
config.logger.debug('All entrypoints:', entrypoints);
|
||||
const skippedEntrypointNames = entrypointInfos
|
||||
.filter((item) => item.skipped)
|
||||
.map((item) => item.name);
|
||||
if (skippedEntrypointNames.length) {
|
||||
config.logger.warn(
|
||||
`Filter excluded the following entrypoints:\n${skippedEntrypointNames
|
||||
.map((item) => `${pc.dim('-')} ${pc.cyan(item)}`)
|
||||
.join('\n')}`,
|
||||
);
|
||||
}
|
||||
const targetEntrypoints = entrypoints.filter((entry) => {
|
||||
const { include, exclude } = entry.options;
|
||||
if (include?.length && exclude?.length) {
|
||||
@@ -125,6 +144,9 @@ export async function findEntrypoints(
|
||||
if (include?.length && !exclude?.length) {
|
||||
return include.includes(config.browser);
|
||||
}
|
||||
if (skippedEntrypointNames.includes(entry.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
@@ -136,6 +158,10 @@ interface EntrypointInfo {
|
||||
name: string;
|
||||
inputPath: string;
|
||||
type: Entrypoint['type'];
|
||||
/**
|
||||
* @default false
|
||||
*/
|
||||
skipped: boolean;
|
||||
}
|
||||
|
||||
function preventDuplicateEntrypointNames(
|
||||
@@ -202,7 +228,7 @@ function getHtmlBaseOptions(document: Document): BaseEntrypointOptions {
|
||||
*/
|
||||
async function getPopupEntrypoint(
|
||||
config: InternalConfig,
|
||||
{ inputPath, name }: EntrypointInfo,
|
||||
{ inputPath, name, skipped }: EntrypointInfo,
|
||||
): Promise<PopupEntrypoint> {
|
||||
const content = await fs.readFile(inputPath, 'utf-8');
|
||||
const { document } = parseHTML(content);
|
||||
@@ -247,6 +273,7 @@ async function getPopupEntrypoint(
|
||||
options,
|
||||
inputPath,
|
||||
outputDir: config.outDir,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,7 +283,7 @@ async function getPopupEntrypoint(
|
||||
*/
|
||||
async function getOptionsEntrypoint(
|
||||
config: InternalConfig,
|
||||
{ inputPath, name }: EntrypointInfo,
|
||||
{ inputPath, name, skipped }: EntrypointInfo,
|
||||
): Promise<OptionsEntrypoint> {
|
||||
const content = await fs.readFile(inputPath, 'utf-8');
|
||||
const { document } = parseHTML(content);
|
||||
@@ -290,6 +317,7 @@ async function getOptionsEntrypoint(
|
||||
options,
|
||||
inputPath,
|
||||
outputDir: config.outDir,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -299,7 +327,7 @@ async function getOptionsEntrypoint(
|
||||
*/
|
||||
async function getUnlistedPageEntrypoint(
|
||||
config: InternalConfig,
|
||||
{ inputPath, name }: EntrypointInfo,
|
||||
{ inputPath, name, skipped }: EntrypointInfo,
|
||||
): Promise<GenericEntrypoint> {
|
||||
const content = await fs.readFile(inputPath, 'utf-8');
|
||||
const { document } = parseHTML(content);
|
||||
@@ -310,6 +338,7 @@ async function getUnlistedPageEntrypoint(
|
||||
inputPath,
|
||||
outputDir: config.outDir,
|
||||
options: getHtmlBaseOptions(document),
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -319,7 +348,7 @@ async function getUnlistedPageEntrypoint(
|
||||
*/
|
||||
async function getUnlistedScriptEntrypoint(
|
||||
config: InternalConfig,
|
||||
{ inputPath, name }: EntrypointInfo,
|
||||
{ inputPath, name, skipped }: EntrypointInfo,
|
||||
): Promise<GenericEntrypoint> {
|
||||
const defaultExport = await importEntrypointFile<UnlistedScriptDefinition>(
|
||||
inputPath,
|
||||
@@ -338,6 +367,7 @@ async function getUnlistedScriptEntrypoint(
|
||||
inputPath,
|
||||
outputDir: config.outDir,
|
||||
options,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -346,7 +376,7 @@ async function getUnlistedScriptEntrypoint(
|
||||
*/
|
||||
async function getBackgroundEntrypoint(
|
||||
config: InternalConfig,
|
||||
{ inputPath, name }: EntrypointInfo,
|
||||
{ inputPath, name, skipped }: EntrypointInfo,
|
||||
): Promise<BackgroundEntrypoint> {
|
||||
let options: Omit<BackgroundDefinition, 'main'> = {};
|
||||
if (inputPath !== VIRTUAL_NOOP_BACKGROUND_MODULE_ID) {
|
||||
@@ -372,6 +402,7 @@ async function getBackgroundEntrypoint(
|
||||
type: resolvePerBrowserOption(options.type, config.browser),
|
||||
persistent: resolvePerBrowserOption(options.persistent, config.browser),
|
||||
},
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -380,7 +411,7 @@ async function getBackgroundEntrypoint(
|
||||
*/
|
||||
async function getContentScriptEntrypoint(
|
||||
config: InternalConfig,
|
||||
{ inputPath, name }: EntrypointInfo,
|
||||
{ inputPath, name, skipped }: EntrypointInfo,
|
||||
): Promise<ContentScriptEntrypoint> {
|
||||
const { main: _, ...options } =
|
||||
await importEntrypointFile<ContentScriptDefinition>(inputPath, config);
|
||||
@@ -395,6 +426,7 @@ async function getContentScriptEntrypoint(
|
||||
inputPath,
|
||||
outputDir: resolve(config.outDir, CONTENT_SCRIPT_OUT_DIR),
|
||||
options,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -79,8 +79,10 @@ import "wxt/browser";
|
||||
declare module "wxt/browser" {
|
||||
export type PublicPath =
|
||||
{{ union }}
|
||||
type HtmlPublicPath = Extract<PublicPath, \`\${string}.html\`>
|
||||
export interface WxtRuntime extends Runtime.Static {
|
||||
getURL(path: PublicPath): string;
|
||||
getURL(path: \`\${HtmlPublicPath}\${string}\`): string;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import path from 'node:path';
|
||||
import { createFsCache } from '~/core/utils/cache';
|
||||
import consola, { LogLevels } from 'consola';
|
||||
import { craeteViteBuilder } from '~/core/builders/vite';
|
||||
import { createViteBuilder } from '~/core/builders/vite';
|
||||
import defu from 'defu';
|
||||
import { NullablyRequired } from '../types';
|
||||
|
||||
@@ -72,6 +72,9 @@ export async function getInternalConfig(
|
||||
srcDir,
|
||||
mergedConfig.entrypointsDir ?? 'entrypoints',
|
||||
);
|
||||
const filterEntrypoints = !!mergedConfig.filterEntrypoints?.length
|
||||
? new Set(mergedConfig.filterEntrypoints)
|
||||
: undefined;
|
||||
const publicDir = path.resolve(srcDir, mergedConfig.publicDir ?? 'public');
|
||||
const typesDir = path.resolve(wxtDir, 'types');
|
||||
const outBaseDir = path.resolve(root, mergedConfig.outDir ?? '.output');
|
||||
@@ -101,6 +104,7 @@ export async function getInternalConfig(
|
||||
command,
|
||||
debug,
|
||||
entrypointsDir,
|
||||
filterEntrypoints,
|
||||
env,
|
||||
fsCache: createFsCache(wxtDir),
|
||||
imports: mergedConfig.imports ?? {},
|
||||
@@ -134,7 +138,7 @@ export async function getInternalConfig(
|
||||
server,
|
||||
};
|
||||
|
||||
const builder = await craeteViteBuilder(
|
||||
const builder = await createViteBuilder(
|
||||
inlineConfig,
|
||||
userConfig,
|
||||
finalConfig,
|
||||
@@ -191,6 +195,8 @@ function mergeInlineConfig(
|
||||
configFile: inlineConfig.configFile,
|
||||
debug: inlineConfig.debug ?? userConfig.debug,
|
||||
entrypointsDir: inlineConfig.entrypointsDir ?? userConfig.entrypointsDir,
|
||||
filterEntrypoints:
|
||||
inlineConfig.filterEntrypoints ?? userConfig.filterEntrypoints,
|
||||
imports,
|
||||
logger: inlineConfig.logger ?? userConfig.logger,
|
||||
manifest,
|
||||
|
||||
@@ -2,7 +2,7 @@ import createJITI, { TransformOptions as JitiTransformOptions } from 'jiti';
|
||||
import { InternalConfig } from '~/types';
|
||||
import { createUnimport } from 'unimport';
|
||||
import fs from 'fs-extra';
|
||||
import { resolve } from 'node:path';
|
||||
import { relative, resolve } from 'node:path';
|
||||
import { getUnimportOptions } from '~/core/utils/unimport';
|
||||
import { removeProjectImportStatements } from '~/core/utils/strings';
|
||||
import { normalizePath } from '~/core/utils/paths';
|
||||
@@ -89,8 +89,17 @@ export async function importEntrypointFile<T>(
|
||||
const res = await jiti(path);
|
||||
return res.default;
|
||||
} catch (err) {
|
||||
config.logger.error(err);
|
||||
throw err;
|
||||
if (err instanceof ReferenceError) {
|
||||
// "XXX is not defined" - usually due to WXT removing imports
|
||||
const variableName = err.message.replace(' is not defined', '');
|
||||
const filePath = relative(config.root, path);
|
||||
throw Error(
|
||||
`${filePath}: Cannot use imported variable "${variableName}" outside the main function. See https://wxt.dev/guide/entrypoints.html#side-effects`,
|
||||
{ cause: err },
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function generateManifest(
|
||||
'Extension version not found, defaulting to "0.0.0". Add a version to your `package.json` or `wxt.config.ts` file. For more details, see: https://wxt.dev/guide/manifest.html#version-and-version-name',
|
||||
);
|
||||
}
|
||||
let version = config.manifest.version ?? simplifyVersion(versionName);
|
||||
const version = config.manifest.version ?? simplifyVersion(versionName);
|
||||
|
||||
const baseManifest: Manifest.WebExtensionManifest = {
|
||||
manifest_version: config.manifestVersion,
|
||||
@@ -80,7 +80,7 @@ export async function generateManifest(
|
||||
'wxt:reload-extension': {
|
||||
description: 'Reload the extension during development',
|
||||
suggested_key: {
|
||||
default: 'Ctrl+E',
|
||||
default: 'Alt+R',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -503,7 +503,10 @@ export function getContentScriptCssWebAccessibleResources(
|
||||
} else {
|
||||
resources.push({
|
||||
resources: [cssFile],
|
||||
matches: script.options.matches,
|
||||
matches: resolvePerBrowserOption(
|
||||
script.options.matches,
|
||||
config.browser,
|
||||
).map((matchPattern) => stripPathFromMatchPattern(matchPattern)),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -547,3 +550,15 @@ function addHostPermission(
|
||||
if (manifest.host_permissions.includes(hostPermission)) return;
|
||||
manifest.host_permissions.push(hostPermission);
|
||||
}
|
||||
|
||||
/**
|
||||
* - "<all_urls>" → "<all_urls>"
|
||||
* - "*://play.google.com/books/*" → "*://play.google.com/*"
|
||||
*/
|
||||
export function stripPathFromMatchPattern(pattern: string) {
|
||||
const protocolSepIndex = pattern.indexOf('://');
|
||||
if (protocolSepIndex === -1) return pattern;
|
||||
|
||||
const startOfPath = pattern.indexOf('/', protocolSepIndex + 3);
|
||||
return pattern.substring(0, startOfPath) + '/*';
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export const fakeContentScriptEntrypoint =
|
||||
name: faker.string.alpha(),
|
||||
options: {
|
||||
matches: [],
|
||||
matchAboutBlank: faker.helpers.arrayElement([true, false, undefined]),
|
||||
matchAboutBlank: faker.datatype.boolean(),
|
||||
matchOriginAsFallback: faker.helpers.arrayElement([
|
||||
true,
|
||||
false,
|
||||
@@ -73,6 +73,7 @@ export const fakeContentScriptEntrypoint =
|
||||
]),
|
||||
},
|
||||
outputDir: fakeDir('.output'),
|
||||
skipped: faker.datatype.boolean(),
|
||||
}));
|
||||
|
||||
export const fakeBackgroundEntrypoint = fakeObjectCreator<BackgroundEntrypoint>(
|
||||
@@ -81,10 +82,11 @@ export const fakeBackgroundEntrypoint = fakeObjectCreator<BackgroundEntrypoint>(
|
||||
inputPath: 'entrypoints/background.ts',
|
||||
name: 'background',
|
||||
options: {
|
||||
persistent: faker.helpers.arrayElement([true, false, undefined]),
|
||||
persistent: faker.datatype.boolean(),
|
||||
type: faker.helpers.maybe(() => 'module'),
|
||||
},
|
||||
outputDir: fakeDir('.output'),
|
||||
skipped: faker.datatype.boolean(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -95,6 +97,7 @@ export const fakeUnlistedScriptEntrypoint =
|
||||
name: faker.string.alpha(),
|
||||
outputDir: fakeDir('.output'),
|
||||
options: {},
|
||||
skipped: faker.datatype.boolean(),
|
||||
}));
|
||||
|
||||
export const fakeOptionsEntrypoint = fakeObjectCreator<OptionsEntrypoint>(
|
||||
@@ -104,10 +107,11 @@ export const fakeOptionsEntrypoint = fakeObjectCreator<OptionsEntrypoint>(
|
||||
name: 'options',
|
||||
outputDir: fakeDir('.output'),
|
||||
options: {
|
||||
browserStyle: faker.helpers.arrayElement([true, false, undefined]),
|
||||
chromeStyle: faker.helpers.arrayElement([true, false, undefined]),
|
||||
openInTab: faker.helpers.arrayElement([true, false, undefined]),
|
||||
browserStyle: faker.datatype.boolean(),
|
||||
chromeStyle: faker.datatype.boolean(),
|
||||
openInTab: faker.datatype.boolean(),
|
||||
},
|
||||
skipped: faker.datatype.boolean(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -134,6 +138,7 @@ export const fakePopupEntrypoint = fakeObjectCreator<PopupEntrypoint>(() => ({
|
||||
undefined,
|
||||
]),
|
||||
},
|
||||
skipped: faker.datatype.boolean(),
|
||||
}));
|
||||
|
||||
export const fakeGenericEntrypoint = fakeObjectCreator<GenericEntrypoint>(
|
||||
@@ -152,6 +157,7 @@ export const fakeGenericEntrypoint = fakeObjectCreator<GenericEntrypoint>(
|
||||
name: faker.string.alpha(),
|
||||
outputDir: fakeDir('.output'),
|
||||
options: {},
|
||||
skipped: faker.datatype.boolean(),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ export interface InlineConfig {
|
||||
* @default "${config.srcDir}/entrypoints"
|
||||
*/
|
||||
entrypointsDir?: string;
|
||||
/**
|
||||
* A list of entrypoint names (`"popup"`, `"options"`, etc.) to build. Will speed up the build if
|
||||
* your extension has lots of entrypoints, and you don't need to build all of them to develop a
|
||||
* feature.
|
||||
*/
|
||||
filterEntrypoints?: string[];
|
||||
/**
|
||||
* Output directory that stored build folders and ZIPs.
|
||||
*
|
||||
@@ -377,6 +383,7 @@ export interface BaseEntrypoint {
|
||||
*/
|
||||
outputDir: string;
|
||||
options: BaseEntrypointOptions;
|
||||
skipped: boolean;
|
||||
}
|
||||
|
||||
export interface GenericEntrypoint extends BaseEntrypoint {
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface InternalConfig {
|
||||
wxtDir: string;
|
||||
typesDir: string;
|
||||
entrypointsDir: string;
|
||||
filterEntrypoints?: Set<string>;
|
||||
outBaseDir: string;
|
||||
outDir: string;
|
||||
debug: boolean;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user