Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 474de83e28 | |||
| 65fcfc0064 | |||
| 2d4983e88b | |||
| 4150e42c05 | |||
| 8d7150653a | |||
| 61e57b7b7c | |||
| 83add72312 | |||
| 3c723d2c92 | |||
| 1eb35c7207 | |||
| 725ecf7c64 | |||
| 3847092df4 | |||
| 19b11c236f | |||
| 4b2012c489 | |||
| 0591050f31 | |||
| b15dc7fc11 | |||
| 89d15babc2 | |||
| c616125689 | |||
| e97071b7d7 | |||
| 1fa049c5a7 | |||
| b48cee9715 | |||
| 24e69fe1bf | |||
| 41e154992a | |||
| 55707932a9 | |||
| 08115a40ae | |||
| 96be879918 | |||
| 1fc4ada66d | |||
| d27f299641 | |||
| 7183114370 |
@@ -0,0 +1,6 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: 80%
|
||||
threshold: 1%
|
||||
@@ -51,6 +51,11 @@ jobs:
|
||||
- name: Tests
|
||||
run: pnpm test:coverage --reporter=default --reporter=hanging-process
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
project-templates:
|
||||
name: Project Templates
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -10,4 +10,5 @@ CHANGELOG.md
|
||||
.prettierignore
|
||||
*.png
|
||||
*.svg
|
||||
*.txt
|
||||
_gitignore
|
||||
|
||||
@@ -1,5 +1,71 @@
|
||||
# Changelog
|
||||
|
||||
## v0.7.1
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.7.0...v0.7.1)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- `createContentScriptUi` helper ([#143](https://github.com/wxt-dev/wxt/pull/143))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Add docs for `createContentScriptUi` ([65fcfc0](https://github.com/wxt-dev/wxt/commit/65fcfc0))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- **release:** V0.7.1-alpha1 ([2d4983e](https://github.com/wxt-dev/wxt/commit/2d4983e))
|
||||
|
||||
## v0.7.0
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.6.6...v0.7.0)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Content script `cssInjectionMode` ([#141](https://github.com/wxt-dev/wxt/pull/141))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Validate transformed manifest correctly ([4b2012c](https://github.com/wxt-dev/wxt/commit/4b2012c))
|
||||
- ⚠️ Output content script CSS to `content-scripts/<name>.css` ([#140](https://github.com/wxt-dev/wxt/pull/140))
|
||||
- Reorder typescript paths to give priority to `@` and `~` over `@@` and `~~` ([#142](https://github.com/wxt-dev/wxt/pull/142))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Store user config metadata in memory ([0591050](https://github.com/wxt-dev/wxt/commit/0591050))
|
||||
|
||||
#### ⚠️ Breaking Changes
|
||||
|
||||
- ⚠️ Content script CSS used to be output to `assets/<name>.css`, but is now `content-scripts/<name>.css` to match the docs. ([#140](https://github.com/wxt-dev/wxt/pull/140))
|
||||
|
||||
## v0.6.6
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.6.5...v0.6.6)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Disable opening browser automatically during dev mode ([#136](https://github.com/wxt-dev/wxt/pull/136))
|
||||
|
||||
## v0.6.5
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.6.4...v0.6.5)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Don't crash when `<all_urls>` matches is used in dev mode ([b48cee9](https://github.com/wxt-dev/wxt/commit/b48cee9))
|
||||
- Support loading `tsx` entrypoints ([#134](https://github.com/wxt-dev/wxt/pull/134))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Add tags for SEO and socials ([96be879](https://github.com/wxt-dev/wxt/commit/96be879))
|
||||
- Add more content to the homepage ([5570793](https://github.com/wxt-dev/wxt/commit/5570793))
|
||||
- Fix DX section sizing ([41e1549](https://github.com/wxt-dev/wxt/commit/41e1549))
|
||||
- Add link to update extensions using WXT ([24e69fe](https://github.com/wxt-dev/wxt/commit/24e69fe))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Code coverage improvements ([#131](https://github.com/wxt-dev/wxt/pull/131))
|
||||
|
||||
## v0.6.4
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.6.3...v0.6.4)
|
||||
|
||||
@@ -3,6 +3,20 @@
|
||||
<span>WXT</span>
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/wxt" target="_blank">
|
||||
<img alt="npm" src="https://img.shields.io/npm/v/wxt?labelColor=black&color=%234fa048">
|
||||
</a>
|
||||
<span> </span>
|
||||
<img alt="npm" src="https://img.shields.io/npm/dm/wxt?labelColor=black&color=%234fa048">
|
||||
<span> </span>
|
||||
<img alt="NPM" src="https://img.shields.io/npm/l/wxt?labelColor=black&color=%234fa048">
|
||||
<span> </span>
|
||||
<a href="https://codecov.io/github/wxt-dev/wxt" target="_blank">
|
||||
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/wxt-dev/wxt?labelColor=black&color=%234fa048">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<span>Next-gen framework for developing web extensions.</span>
|
||||
<br/>
|
||||
|
||||
+5
-1
@@ -16,9 +16,13 @@
|
||||
"postinstall": "pnpm -w build && wxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"webextension-polyfill": "^0.10.0"
|
||||
"webextension-polyfill": "^0.10.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"@types/webextension-polyfill": "^0.10.0",
|
||||
"sass": "^1.64.0",
|
||||
"wxt": "workspace:*"
|
||||
|
||||
+10
-3
@@ -1,7 +1,7 @@
|
||||
import '../../common/style.css';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['*://*/*'],
|
||||
matches: ['<all_urls>'],
|
||||
async main(ctx) {
|
||||
console.log(browser.runtime.id);
|
||||
logId();
|
||||
@@ -13,6 +13,13 @@ export default defineContentScript({
|
||||
console.log(n, browser.runtime.id);
|
||||
}, 1e3);
|
||||
|
||||
mountContentScriptUi();
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
|
||||
ReactDOM.createRoot(container).render(<SomeComponent />);
|
||||
},
|
||||
});
|
||||
|
||||
function SomeComponent() {
|
||||
return <div>Some component</div>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import '../../common/style.css';
|
||||
import './style.css';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['https://*.duckduckgo.com/*'],
|
||||
cssInjectionMode: 'ui',
|
||||
|
||||
async main(ctx) {
|
||||
const ui = await createContentScriptUi(ctx, {
|
||||
name: 'demo-ui',
|
||||
type: 'inline',
|
||||
append: 'before',
|
||||
anchor: 'form[role=search]',
|
||||
mount: (container) => {
|
||||
const app = document.createElement('div');
|
||||
app.textContent = 'Custom content script UI';
|
||||
container.append(app);
|
||||
},
|
||||
});
|
||||
ui.mount();
|
||||
|
||||
setTimeout(ui.remove, 5000);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
color: indianred;
|
||||
}
|
||||
html {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
div {
|
||||
padding: 16px;
|
||||
}
|
||||
+5
-1
@@ -1,3 +1,7 @@
|
||||
{
|
||||
"extends": ["../tsconfig.base.json", "./.wxt/tsconfig.json"]
|
||||
"extends": ["../tsconfig.base.json", "./.wxt/tsconfig.json"],
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,68 @@
|
||||
<script lang="ts" setup>
|
||||
import useListExtensionDetails from '../composables/useListExtensionDetails';
|
||||
import { computed } from 'vue';
|
||||
import useListExtensionDetails, {
|
||||
ChromeExtension,
|
||||
} from '../composables/useListExtensionDetails';
|
||||
|
||||
// Add extension IDs here. Order doesn't matter, will be sorted by weekly active users
|
||||
const chromeExtensionIds = [
|
||||
'ocfdgncpifmegplaglcnglhioflaimkd', // GitHub: Better Line Counts
|
||||
'mgmdkjcljneegjfajchedjpdhbadklcf', // Anime Skip Player
|
||||
];
|
||||
|
||||
const { data } = useListExtensionDetails(chromeExtensionIds);
|
||||
const sortedExtensions = computed(() =>
|
||||
!data.value
|
||||
? undefined
|
||||
: [...data.value].sort((l, r) => r.weeklyActiveUsers - l.weeklyActiveUsers),
|
||||
);
|
||||
|
||||
function getStoreUrl(extension: ChromeExtension) {
|
||||
const url = new URL(extension.storeUrl);
|
||||
url.searchParams.set('utm_source', 'wxt.dev');
|
||||
return url.href;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="vp-doc">
|
||||
<div class="container">
|
||||
<h2>Who's Using WXT?</h2>
|
||||
<p>
|
||||
Battle tested and ready for production. Explore chrome extensions made
|
||||
with WXT.
|
||||
</p>
|
||||
<ul>
|
||||
<li v-for="extension of data">
|
||||
<li v-for="extension of sortedExtensions">
|
||||
<img
|
||||
:src="extension.iconUrl"
|
||||
:alt="`${extension.name} icon`"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<a :href="extension.storeUrl" target="_blank">{{ extension.name }}</a>
|
||||
<small>{{ extension.shortDescription }}</small>
|
||||
<div>
|
||||
<a
|
||||
:href="getStoreUrl(extension)"
|
||||
target="_blank"
|
||||
:title="extension.name"
|
||||
>{{ extension.name }}</a
|
||||
>
|
||||
<p class="description" :title="extension.shortDescription">
|
||||
{{ extension.shortDescription }}
|
||||
</p>
|
||||
<p class="user-count">
|
||||
{{ extension.weeklyActiveUsers.toLocaleString() }} users
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p>Open a PR to add your extension to the list!</p>
|
||||
<p class="centered pr">
|
||||
<a
|
||||
href="https://github.com/wxt-dev/wxt/edit/main/docs/.vitepress/components/UsingWxtSection.vue"
|
||||
target="_blank"
|
||||
>Open a PR</a
|
||||
>
|
||||
to add your extension to the list!
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -33,10 +72,6 @@ const { data } = useListExtensionDetails(chromeExtensionIds);
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.vp-doc {
|
||||
padding: 0 48px;
|
||||
@@ -56,58 +91,86 @@ h2 {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
margin-bottom: 16px;
|
||||
li img {
|
||||
width: 116px;
|
||||
height: 116px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-default-soft);
|
||||
}
|
||||
|
||||
ul {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
margin: 16px 0;
|
||||
padding: 0;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
ul {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
ul {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0 !important;
|
||||
padding: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
a,
|
||||
small {
|
||||
text-align: center;
|
||||
}
|
||||
small {
|
||||
opacity: 50%;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
.centered {
|
||||
text-align: center;
|
||||
opacity: 50%;
|
||||
}
|
||||
a {
|
||||
color: var(--vp-c-text-1);
|
||||
|
||||
li a,
|
||||
li .user-count,
|
||||
li .description {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
li .user-count {
|
||||
opacity: 70%;
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
li a {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
li a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
li div {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
li .description {
|
||||
opacity: 90%;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.pr {
|
||||
opacity: 70%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
import { defineConfig } from 'vitepress';
|
||||
import { generateConfigDocs } from './plugins/generate-config-docs';
|
||||
|
||||
const title = 'Next-gen Web Extension Framework';
|
||||
const titleSuffix = ' – WXT';
|
||||
|
||||
const description =
|
||||
"WXT provides the best developer experience, making it quick, easy, and fun to develop chrome extensions for all browsers. With built-in utilties for building, zipping, and publishing your extension, it's easy to get started.";
|
||||
const ogTitle = `${title}${titleSuffix}`;
|
||||
const ogUrl = 'https://wxt.dev';
|
||||
const ogImage = 'https://wxt.dev/social-preview.png';
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
titleTemplate: `:title${titleSuffix}`,
|
||||
title: 'WXT',
|
||||
description,
|
||||
vite: {
|
||||
clearScreen: false,
|
||||
plugins: [generateConfigDocs()],
|
||||
},
|
||||
description: 'Next gen framework for developing web extensions',
|
||||
lastUpdated: true,
|
||||
sitemap: {
|
||||
hostname: 'https://wxt.dev',
|
||||
},
|
||||
|
||||
head: [
|
||||
['meta', { property: 'og:type', content: 'website' }],
|
||||
['meta', { property: 'og:title', content: ogTitle }],
|
||||
['meta', { property: 'og:image', content: ogImage }],
|
||||
['meta', { property: 'og:url', content: ogUrl }],
|
||||
['meta', { property: 'og:description', content: description }],
|
||||
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
|
||||
[
|
||||
'script',
|
||||
{
|
||||
@@ -24,7 +43,10 @@ export default defineConfig({
|
||||
|
||||
themeConfig: {
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
logo: '/logo.svg',
|
||||
logo: {
|
||||
src: '/logo.svg',
|
||||
alt: 'WXT logo',
|
||||
},
|
||||
editLink: {
|
||||
pattern: 'https://github.com/wxt-dev/wxt/edit/main/docs/:path',
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# API Reference
|
||||
|
||||
:::warning 🚧 Under construction
|
||||
This documentation does not exist yet.
|
||||
This documentation does not exist yet. All APIs are documented with JSDoc, so for now, you can view the documentation in your editor.
|
||||
:::
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
---
|
||||
head:
|
||||
- - link
|
||||
- rel: canonical
|
||||
href: https://wxt.dev
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
WXT is a free and open source framework for building web extensions in an conventional, intuative, and safe way **_for all browsers_**.
|
||||
|
||||
@@ -13,6 +13,7 @@ Some WXT APIs can be used without importing them:
|
||||
- [`browser`](/config.md#browser) from `wxt/browser`, a small wrapper around `webextension-polyfill`
|
||||
- [`defineContentScript`](/config.md#defiencontentscript) from `wxt/client`
|
||||
- [`defineBackground`](/config.md#definebackgroundscript) from `wxt/client`
|
||||
- [`createContentScriptUi`](/config.md#createcontentscriptui) from `wxt/client`
|
||||
|
||||
And more. All [`wxt/client`](/config.md#wxtclient) APIs can be used without imports.
|
||||
|
||||
|
||||
+211
-41
@@ -34,6 +34,9 @@ export default defineContentScript({
|
||||
include: undefined | string[],
|
||||
exclude: undefined | string[],
|
||||
|
||||
// Configure how CSS is injected onto the page
|
||||
cssInjectionMode: undefined | "manifest" | "manual" | "ui",
|
||||
|
||||
main(ctx) {
|
||||
// Executed when content script is loaded
|
||||
},
|
||||
@@ -44,49 +47,9 @@ export default defineContentScript({
|
||||
|
||||
When defining multiple content scripts, content script entrypoints that have the same set of options will be merged into a single `content_script` item in the manifest.
|
||||
|
||||
## CSS
|
||||
|
||||
To include CSS with your content script, import the CSS file at the top of your entrypoint.
|
||||
|
||||
```
|
||||
|
||||
<srcDir>
|
||||
└─ entrypoints/
|
||||
└─ overlay.content/
|
||||
├─ index.ts
|
||||
└─ style.css
|
||||
```
|
||||
|
||||
```ts
|
||||
// entrypoints/overlay.content/index.ts
|
||||
import './style.css';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['*://google.com/*', '*://duckduckgo.com/*'],
|
||||
main(ctx) {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Any styles imported in your content script will be added to that content script's `css` array in your `manifest.json`:
|
||||
|
||||
```json
|
||||
// .output/chrome-mv3/manifest.json
|
||||
{
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["*://google.com/*", "*://duckduckgo.com/*"],
|
||||
"js": ["content-scripts/overlay.js"],
|
||||
"css": ["content-scripts/overlay.css"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
Old content scripts are not automatically stopped when an extension updates and restarts. Often, this leads to "Invalidated context" errors in production when a content script from an old version of your extension tries to use a web extension API. Since it's not connected to the latest version of your extension, the browser decides to throw an error.
|
||||
Old content scripts are not automatically stopped when an extension updates and reloads. Often, this leads to "Invalidated context" errors in production when a content script from an old version of your extension tries to use a extension API.
|
||||
|
||||
WXT provides a utility for managing this process: `ContentScriptContext`. An instance of this class is provided to you automatically inside the `main` function of your content script.
|
||||
|
||||
@@ -120,3 +83,210 @@ When working with content scripts, **you should always use the `ctx` object to s
|
||||
|
||||
This prevents old content scripts from interfering with new content scripts, and prevents error messages from the console in production.
|
||||
:::
|
||||
|
||||
## CSS
|
||||
|
||||
To include CSS with your content script, import the CSS file at the top of your entrypoint.
|
||||
|
||||
```
|
||||
|
||||
<srcDir>
|
||||
└─ entrypoints/
|
||||
└─ overlay.content/
|
||||
├─ index.ts
|
||||
└─ style.css
|
||||
```
|
||||
|
||||
```ts
|
||||
// entrypoints/overlay.content/index.ts
|
||||
import './style.css';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['*://google.com/*', '*://duckduckgo.com/*'],
|
||||
|
||||
main(ctx) {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Any styles imported in your content script will be added to that content script's `css` array in your `manifest.json`:
|
||||
|
||||
```json
|
||||
// .output/chrome-mv3/manifest.json
|
||||
{
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["*://google.com/*", "*://duckduckgo.com/*"],
|
||||
"js": ["content-scripts/overlay.js"],
|
||||
"css": ["content-scripts/overlay.css"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
To disable this behavior, set `cssInjectionMode` to `"manual"` or `"ui"`.
|
||||
|
||||
```ts
|
||||
export default defineContentScript({
|
||||
matches: ['*://google.com/*', '*://duckduckgo.com/*'],
|
||||
cssInjectionMode: 'manual',
|
||||
|
||||
main(ctx) {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## UI
|
||||
|
||||
WXT provides a utility function, `createContentScriptUi` to simplify mounting a UI from a content script. Internally, it uses the `ShadowRoot` API to isolate your CSS from the webpages.
|
||||
|
||||
`createContentScriptUi` requires a `ContentScriptContext` so that when the context is invalidated, the UI is automatically removed from the webpage.
|
||||
|
||||
:::details When to use `createContentScriptUi`
|
||||
You should only use `createContentScriptUi` if you want your UI's styles isolated from the webpages. If you want to create a more "integrated" UI that uses the page's styles, you can just use the regular JS API's to append your UI to the page.
|
||||
|
||||
```ts
|
||||
const ui = document.createElement('div');
|
||||
const anchor = document.querySelector('#anchor-selector');
|
||||
anchor.append(ui);
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
### Usage
|
||||
|
||||
To use `createContentScriptUi`, follow these steps:
|
||||
|
||||
1. Import your CSS file at the top of your content script
|
||||
2. Set `cssInjectionMode: "ui"` inside `defineContentScript`
|
||||
3. Call `createContentScriptUi`
|
||||
4. Call `ui.mount()` to add the UI to the webpage
|
||||
|
||||
Here's a basic example:
|
||||
|
||||
```ts
|
||||
// entrypoints/ui.content/index.ts
|
||||
import './style.css';
|
||||
|
||||
export default defineContentScript({
|
||||
// ...
|
||||
cssInjectionMode: 'ui',
|
||||
|
||||
async main(ctx) {
|
||||
const ui = await createContentScriptUi(ctx, {
|
||||
name: 'example-ui',
|
||||
type: 'inline',
|
||||
anchor: '#some-element',
|
||||
append: 'after',
|
||||
mount(container) {
|
||||
// Mount UI inside `container`...
|
||||
},
|
||||
});
|
||||
|
||||
// Yoy must call `mount` to add the UI to the page.
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
If you're using a frontend framework, you'll also need to include an `onRemoved` callback:
|
||||
|
||||
:::code-group
|
||||
|
||||
```ts [Vue]
|
||||
import { createApp } from 'vue';
|
||||
|
||||
createContentScriptUi(ctx, {
|
||||
// ...
|
||||
mount(container) {
|
||||
// Create a new app and mount it inside the container
|
||||
const app = createApp(...);
|
||||
app.mount(container);
|
||||
return app;
|
||||
},
|
||||
onRemove(app) {
|
||||
// When the UI is removed from the DOM, call unmount to stop the app
|
||||
app.unmount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts [React]
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
createContentScriptUi(ctx, {
|
||||
// ...
|
||||
mount(container) {
|
||||
// Create a root using the container and render your app
|
||||
const root = ReactDOM.createRoot(container);
|
||||
root.render(...);
|
||||
return root;
|
||||
},
|
||||
onRemove(root) {
|
||||
// When the UI is removed from the DOM, call unmount to stop the app
|
||||
root.unmount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts [Svelte]
|
||||
import App from './App.svelte';
|
||||
|
||||
createContentScriptUi(ctx, {
|
||||
// ...
|
||||
mount(container) {
|
||||
// Mount your app component inside the container
|
||||
return new App({
|
||||
target: container,
|
||||
});
|
||||
},
|
||||
onRemove(app) {
|
||||
// When the UI is removed from the DOM, call $destroy to stop the app
|
||||
app.$destroy();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts [Solid]
|
||||
import { render } from 'solid-js/web';
|
||||
|
||||
createContentScriptUi(ctx, {
|
||||
// ...
|
||||
mount(container) {
|
||||
// Render your app component into the container
|
||||
return render(() => ..., container)
|
||||
},
|
||||
onRemove(unmount) {
|
||||
// When the UI is removed from the DOM, call unmount to stop the app
|
||||
unmount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
### `anchor`
|
||||
|
||||
The anchor dictates where the UI will be mounted.
|
||||
|
||||
### `append`
|
||||
|
||||
Customize where the UI get's appended to the DOM, relative to the `anchor` element.
|
||||
|
||||
### `type`
|
||||
|
||||
There are 3 types of UI's you can mount.
|
||||
|
||||
- `inline`: Shows up inline based on the `anchor` and `append` options
|
||||
- `overlay`: Shows up inline, but styled to be 0px by 0px, with overflow visible. This causes the UI to overlay on top of the webpage's content
|
||||
- `modal`: A fullscreen overlay that covers the entire screen, regardless of where it's anchored.
|
||||
|
||||
> TODO: Add visualization of the different UI types.
|
||||
|
||||
### Overlay `alignment`
|
||||
|
||||
Because the overlay UI type results in a 0px by 0px container being added to the webpage, the `alignment` option allows you to configure which corner of your UI is aligned with the 0x0 element.
|
||||
|
||||
> TODO: Add visualization of the different alignments.
|
||||
|
||||
+48
-10
@@ -1,12 +1,12 @@
|
||||
---
|
||||
# https://vitepress.dev/reference/default-theme-home-page
|
||||
layout: home
|
||||
titleTemplate: 'Next Generation Web Extension Framework'
|
||||
title: Next-gen Web Extension Framework
|
||||
|
||||
hero:
|
||||
name: WXT
|
||||
text: Next-gen Web Extension Framework
|
||||
tagline: Powered by Vite, inspired by Nuxt.
|
||||
tagline: An open source tool that makes Chrome Extension devlopment faster than ever before.
|
||||
image:
|
||||
src: /hero-logo.svg
|
||||
alt: WXT
|
||||
@@ -21,39 +21,77 @@ hero:
|
||||
features:
|
||||
- icon: 🌐
|
||||
title: Supported Browsers
|
||||
details: Chrome, Firefox, Edge, Safari, and any Chromium based browser.
|
||||
details: WXT will build extensions for Chrome, Firefox, Edge, Safari, and any Chromium based browser.
|
||||
link: /get-started/build-targets
|
||||
linkText: Read docs
|
||||
- icon: ✅
|
||||
title: MV2 and MV3
|
||||
details: Supports both manifest versions for each browser.
|
||||
details: Build Manifest V2 or V3 extensions for any browser using the same codebase.
|
||||
link: /get-started/build-targets#target-manifest-version
|
||||
linkText: Read docs
|
||||
- icon: ⚡
|
||||
title: Fast Dev Mode
|
||||
details: HMR for UIs and fast reload for background and content scripts.
|
||||
details: Lighting fast HMR for UI development and fast reloads for content/background scripts enables faster iterations.
|
||||
link: /get-started/introduction.html#development
|
||||
linkText: Learn more
|
||||
- icon: 📂
|
||||
title: File Based Entrypoints
|
||||
details: Manifest is generated based on files inside the project.
|
||||
details: Manifest is generated based on files in the project with inline configuration.
|
||||
link: /get-started/entrypoints
|
||||
linkText: See project structure
|
||||
- icon: 🚔
|
||||
title: TypeScript
|
||||
details: Scale projects with full TS support.
|
||||
details: Create large projects with confidence using TS by default.
|
||||
- icon: 🦾
|
||||
title: Auto-imports
|
||||
details: Nuxt-like auto-imports to speed up development.
|
||||
link: /guide/auto-imports
|
||||
linkText: Read docs
|
||||
- icon: ⬇️
|
||||
title: Bundle Remote Code
|
||||
details: Downloads and bundles remote code imported from URLs.
|
||||
link: guide/remote-code
|
||||
linkText: Read docs
|
||||
- icon: 🎨
|
||||
title: Frontend Framework Agnostic
|
||||
details: Works with any front-end framework with a Vite plugin.
|
||||
link: /get-started/configuration#frontend-frameworks
|
||||
linkText: Add a framework
|
||||
- icon: 🖍️
|
||||
title: Bootstrap a New Project
|
||||
details: Comes with starter templates for all major frontend frameworks.
|
||||
details: Get started quickly with several awesome project templates.
|
||||
link: /get-started/installation#bootstrap-project
|
||||
linkText: See templates
|
||||
- icon: 📏
|
||||
title: Bundle Analysis
|
||||
details: 'Tools for analyizing the final extension bundle.'
|
||||
details: Tools for analyizing the final extension bundle and minimizing your extension's size.
|
||||
- icon: 🤖
|
||||
title: Automated Publishing
|
||||
details: 'TODO: Automatically zip, upload, and release extensions.'
|
||||
details: 'Coming soon. Automatically zip, upload, and release extensions.'
|
||||
---
|
||||
|
||||
<section class="vp-doc">
|
||||
<div class="container">
|
||||
<h2>Put <span style="color: var(--vp-c-brand-1)">Developer Experience</span> First</h2>
|
||||
<p>
|
||||
WXT's simplifies the chrome extension development process by providing tools for zipping and publishing, the best-in-class dev mode, an opinionated project structure, and more. Iterate faster, develop features not build scripts, and use everything the JS ecosystem has to offer.
|
||||
</p>
|
||||
<div style="margin: auto; width: 100%; max-width: 700px; text-align: center">
|
||||
<img src="./assets/cli-output.png" alt="Screenshot of WXT's build output" />
|
||||
<small>
|
||||
And who doesn't appreciate a beautiful CLI?
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ClientOnly>
|
||||
<UsingWxtSection />
|
||||
</ClientOnly>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
margin: 0 auto;
|
||||
max-width: 1152px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
User-agent: *
|
||||
Disallow: /api.html
|
||||
Disallow: /config.html
|
||||
|
||||
Sitemap: https://wxt.dev/sitemap.xml
|
||||
@@ -18,10 +18,10 @@ describe('Auto Imports', () => {
|
||||
declare global {
|
||||
const ContentScriptContext: typeof import('wxt/client')['ContentScriptContext']
|
||||
const browser: typeof import('wxt/browser')['browser']
|
||||
const createContentScriptUi: typeof import('wxt/client')['createContentScriptUi']
|
||||
const defineBackground: typeof import('wxt/client')['defineBackground']
|
||||
const defineConfig: typeof import('wxt')['defineConfig']
|
||||
const defineContentScript: typeof import('wxt/client')['defineContentScript']
|
||||
const mountContentScriptUi: typeof import('wxt/client')['mountContentScriptUi']
|
||||
}
|
||||
"
|
||||
`);
|
||||
|
||||
@@ -193,118 +193,295 @@ describe('Manifest Content', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should group content scripts and styles together based on their matches and run_at', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/one.content/index.ts',
|
||||
`import "./style.css";
|
||||
describe('content_scripts', () => {
|
||||
it('should group content scripts and styles together based on their matches and run_at', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/one.content/index.ts',
|
||||
`import "./style.css";
|
||||
export default defineContentScript({
|
||||
matches: ["*://google.com/*"],
|
||||
main: () => {},
|
||||
})`,
|
||||
);
|
||||
project.addFile('entrypoints/one.content/style.css', `body { color: red }`);
|
||||
project.addFile(
|
||||
'entrypoints/two.content/index.ts',
|
||||
`import "./style.css";
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/one.content/style.css',
|
||||
`body { color: red }`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/two.content/index.ts',
|
||||
`import "./style.css";
|
||||
export default defineContentScript({
|
||||
matches: ["*://google.com/*"],
|
||||
runAt: "document_end",
|
||||
main: () => {},
|
||||
})`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/two.content/style.css',
|
||||
`body { color: green }`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/three.content/index.ts',
|
||||
`import "./style.css";
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/two.content/style.css',
|
||||
`body { color: green }`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/three.content/index.ts',
|
||||
`import "./style.css";
|
||||
export default defineContentScript({
|
||||
matches: ["*://google.com/*"],
|
||||
runAt: "document_end",
|
||||
main: () => {},
|
||||
})`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/three.content/style.css',
|
||||
`body { color: blue }`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/four.content/index.ts',
|
||||
`import "./style.css";
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/three.content/style.css',
|
||||
`body { color: blue }`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/four.content/index.ts',
|
||||
`import "./style.css";
|
||||
export default defineContentScript({
|
||||
matches: ["*://duckduckgo.com/*"],
|
||||
runAt: "document_end",
|
||||
main: () => {},
|
||||
})`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/four.content/style.css',
|
||||
`body { color: yellow }`,
|
||||
);
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/four.content/style.css',
|
||||
`body { color: yellow }`,
|
||||
);
|
||||
|
||||
await project.build();
|
||||
await project.build();
|
||||
|
||||
const manifest = await project.getOutputManifest();
|
||||
const manifest = await project.getOutputManifest();
|
||||
|
||||
expect(manifest.content_scripts).toContainEqual({
|
||||
matches: ['*://duckduckgo.com/*'],
|
||||
run_at: 'document_end',
|
||||
css: ['assets/four.css'],
|
||||
js: ['content-scripts/four.js'],
|
||||
expect(manifest.content_scripts).toContainEqual({
|
||||
matches: ['*://duckduckgo.com/*'],
|
||||
run_at: 'document_end',
|
||||
css: ['content-scripts/four.css'],
|
||||
js: ['content-scripts/four.js'],
|
||||
});
|
||||
expect(manifest.content_scripts).toContainEqual({
|
||||
matches: ['*://google.com/*'],
|
||||
run_at: 'document_end',
|
||||
css: ['content-scripts/three.css', 'content-scripts/two.css'],
|
||||
js: ['content-scripts/three.js', 'content-scripts/two.js'],
|
||||
});
|
||||
expect(manifest.content_scripts).toContainEqual({
|
||||
matches: ['*://google.com/*'],
|
||||
css: ['content-scripts/one.css'],
|
||||
js: ['content-scripts/one.js'],
|
||||
});
|
||||
});
|
||||
expect(manifest.content_scripts).toContainEqual({
|
||||
matches: ['*://google.com/*'],
|
||||
run_at: 'document_end',
|
||||
css: ['assets/three.css', 'assets/two.css'],
|
||||
js: ['content-scripts/three.js', 'content-scripts/two.js'],
|
||||
});
|
||||
expect(manifest.content_scripts).toContainEqual({
|
||||
matches: ['*://google.com/*'],
|
||||
css: ['assets/one.css'],
|
||||
js: ['content-scripts/one.js'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should add to any content scripts declared in wxt.config.ts', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/one.content/index.ts',
|
||||
`export default defineContentScript({
|
||||
it('should add to any content scripts declared in wxt.config.ts', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/one.content/index.ts',
|
||||
`export default defineContentScript({
|
||||
matches: ["*://google.com/*"],
|
||||
main: () => {},
|
||||
})`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/two.content/style.css',
|
||||
`body {
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/two.content/style.css',
|
||||
`body {
|
||||
background-color: red;
|
||||
}`,
|
||||
);
|
||||
project.setConfigFileConfig({
|
||||
manifest: {
|
||||
content_scripts: [
|
||||
{
|
||||
css: ['content-scripts/two.css'],
|
||||
matches: ['*://*.google.com/*'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await project.build();
|
||||
|
||||
const manifest = await project.getOutputManifest();
|
||||
|
||||
expect(manifest.content_scripts).toContainEqual({
|
||||
css: ['content-scripts/two.css'],
|
||||
matches: ['*://*.google.com/*'],
|
||||
});
|
||||
expect(manifest.content_scripts).toContainEqual({
|
||||
matches: ['*://google.com/*'],
|
||||
js: ['content-scripts/one.js'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should add a CSS entry when cssInjectionMode is undefined', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/content/style.css',
|
||||
'body { background-color: red; }',
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/content/index.ts',
|
||||
`import "./style.css";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["https://*.google.com/*"],
|
||||
|
||||
main() {},
|
||||
});`,
|
||||
);
|
||||
await project.build();
|
||||
|
||||
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"css\\":[\\"content-scripts/content.css\\"],\\"js\\":[\\"content-scripts/content.js\\"]}]}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should add a CSS entry when cssInjectionMode is "manifest"', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/content/style.css',
|
||||
'body { background-color: red; }',
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/content/index.ts',
|
||||
`import "./style.css";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["https://*.google.com/*"],
|
||||
cssInjectionMode: "manifest",
|
||||
|
||||
main() {},
|
||||
});`,
|
||||
);
|
||||
await project.build();
|
||||
|
||||
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"css\\":[\\"content-scripts/content.css\\"],\\"js\\":[\\"content-scripts/content.js\\"]}]}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should not add an entry for CSS when cssInjectionMode is "manual"', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/content/style.css',
|
||||
'body { background-color: red; }',
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/content/index.ts',
|
||||
`import "./style.css";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["https://*.google.com/*"],
|
||||
cssInjectionMode: "manual",
|
||||
|
||||
main() {},
|
||||
});`,
|
||||
);
|
||||
await project.build();
|
||||
|
||||
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]}]}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should not add a content script entry for CSS when cssInjectionMode is "ui", but add a web_accessible_resources entry for MV2', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/content/style.css',
|
||||
'body { background-color: red; }',
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/content/index.ts',
|
||||
`import "./style.css";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["https://*.google.com/*"],
|
||||
cssInjectionMode: "ui",
|
||||
|
||||
main() {},
|
||||
});`,
|
||||
);
|
||||
await project.build({
|
||||
manifestVersion: 2,
|
||||
});
|
||||
|
||||
expect(await project.serializeFile('.output/chrome-mv2/manifest.json'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv2/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":2,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]}],\\"web_accessible_resources\\":[\\"content-scripts/content.css\\"]}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should not add a content script entry for CSS when cssInjectionMode is "ui", but add a web_accessible_resources entry for MV3', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/content/style.css',
|
||||
'body { background-color: red; }',
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/content/index.ts',
|
||||
`import "./style.css";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["https://*.google.com/*"],
|
||||
cssInjectionMode: "ui",
|
||||
|
||||
main() {},
|
||||
});`,
|
||||
);
|
||||
await project.build({
|
||||
manifestVersion: 3,
|
||||
});
|
||||
|
||||
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]}],\\"web_accessible_resources\\":[{\\"resources\\":[\\"content-scripts/content.css\\"],\\"matches\\":[\\"https://*.google.com/*\\"]}]}"
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should combine web accessible resources', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/content/style.css',
|
||||
'body { background-color: red; }',
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/content/index.ts',
|
||||
`import "./style.css";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["https://*.google.com/*"],
|
||||
cssInjectionMode: "ui",
|
||||
|
||||
main() {},
|
||||
});`,
|
||||
);
|
||||
project.setConfigFileConfig({
|
||||
manifest: {
|
||||
content_scripts: [
|
||||
{
|
||||
css: ['content-scripts/two.css'],
|
||||
matches: ['*://*.google.com/*'],
|
||||
},
|
||||
web_accessible_resources: [
|
||||
{ resources: ['one.png'], matches: ['https://one.com/*'] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await project.build();
|
||||
|
||||
const manifest = await project.getOutputManifest();
|
||||
|
||||
expect(manifest.content_scripts).toContainEqual({
|
||||
css: ['content-scripts/two.css'],
|
||||
matches: ['*://*.google.com/*'],
|
||||
});
|
||||
expect(manifest.content_scripts).toContainEqual({
|
||||
matches: ['*://google.com/*'],
|
||||
js: ['content-scripts/one.js'],
|
||||
});
|
||||
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"web_accessible_resources\\":[{\\"resources\\":[\\"one.png\\"],\\"matches\\":[\\"https://one.com/*\\"]},{\\"resources\\":[\\"content-scripts/content.css\\"],\\"matches\\":[\\"https://*.google.com/*\\"]}],\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]}]}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should respect the transformManifest option', async () => {
|
||||
|
||||
@@ -49,19 +49,19 @@ describe('Output Directory Structure', () => {
|
||||
'.output/chrome-mv3/content-scripts/two.js',
|
||||
]),
|
||||
).toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/assets/one.css
|
||||
".output/chrome-mv3/content-scripts/one.css
|
||||
----------------------------------------
|
||||
body{color:#00f}
|
||||
|
||||
================================================================================
|
||||
.output/chrome-mv3/assets/two.css
|
||||
----------------------------------------
|
||||
body{color:red}
|
||||
|
||||
================================================================================
|
||||
.output/chrome-mv3/content-scripts/one.js
|
||||
----------------------------------------
|
||||
<contents-ignored>
|
||||
================================================================================
|
||||
.output/chrome-mv3/content-scripts/two.css
|
||||
----------------------------------------
|
||||
body{color:red}
|
||||
|
||||
================================================================================
|
||||
.output/chrome-mv3/content-scripts/two.js
|
||||
----------------------------------------
|
||||
@@ -69,7 +69,7 @@ describe('Output Directory Structure', () => {
|
||||
================================================================================
|
||||
.output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"content_scripts\\":[{\\"matches\\":[\\"*://*/*\\"],\\"css\\":[\\"assets/one.css\\",\\"assets/two.css\\"],\\"js\\":[\\"content-scripts/one.js\\",\\"content-scripts/two.js\\"]}]}"
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"content_scripts\\":[{\\"matches\\":[\\"*://*/*\\"],\\"css\\":[\\"content-scripts/one.css\\",\\"content-scripts/two.css\\"],\\"js\\":[\\"content-scripts/one.js\\",\\"content-scripts/two.js\\"]}]}"
|
||||
`);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TestProject } from '../utils';
|
||||
|
||||
describe('React', () => {
|
||||
it('should prepare and build an project with a tsx entrypoint', async () => {
|
||||
const project = new TestProject({
|
||||
dependencies: {
|
||||
react: '^18.2.0',
|
||||
'react-dom': '^18.2.0',
|
||||
},
|
||||
devDependencies: {
|
||||
'@types/react': '^18.2.14',
|
||||
'@types/react-dom': '^18.2.6',
|
||||
},
|
||||
});
|
||||
project.addFile(
|
||||
'entrypoints/demo.content.tsx',
|
||||
`import ReactDOM from 'react-dom/client';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: "<all_urls>",
|
||||
main() {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container)
|
||||
const root = ReactDOM.createRoot(container);
|
||||
root.render(<h1>Hello, world!</h1>);
|
||||
}
|
||||
})`,
|
||||
);
|
||||
|
||||
await project.build();
|
||||
|
||||
expect(
|
||||
await project.fileExists('.output/chrome-mv3/content-scripts/demo.js'),
|
||||
).toBe(true);
|
||||
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
|
||||
.toMatchInlineSnapshot(`
|
||||
".output/chrome-mv3/manifest.json
|
||||
----------------------------------------
|
||||
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"content_scripts\\":[{\\"matches\\":\\"<all_urls>\\",\\"js\\":[\\"content-scripts/demo.js\\"]}]}"
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -255,14 +255,14 @@ describe('TypeScript Project', () => {
|
||||
\\"lib\\": [\\"DOM\\", \\"WebWorker\\"],
|
||||
\\"skipLibCheck\\": true,
|
||||
\\"paths\\": {
|
||||
\\"@@\\": [\\"..\\"],
|
||||
\\"@@/*\\": [\\"../*\\"],
|
||||
\\"~~\\": [\\"..\\"],
|
||||
\\"~~/*\\": [\\"../*\\"],
|
||||
\\"@\\": [\\"..\\"],
|
||||
\\"@/*\\": [\\"../*\\"],
|
||||
\\"~\\": [\\"..\\"],
|
||||
\\"~/*\\": [\\"../*\\"]
|
||||
\\"~/*\\": [\\"../*\\"],
|
||||
\\"@@\\": [\\"..\\"],
|
||||
\\"@@/*\\": [\\"../*\\"],
|
||||
\\"~~\\": [\\"..\\"],
|
||||
\\"~~/*\\": [\\"../*\\"]
|
||||
}
|
||||
},
|
||||
\\"include\\": [
|
||||
@@ -299,14 +299,14 @@ describe('TypeScript Project', () => {
|
||||
\\"lib\\": [\\"DOM\\", \\"WebWorker\\"],
|
||||
\\"skipLibCheck\\": true,
|
||||
\\"paths\\": {
|
||||
\\"@@\\": [\\"..\\"],
|
||||
\\"@@/*\\": [\\"../*\\"],
|
||||
\\"~~\\": [\\"..\\"],
|
||||
\\"~~/*\\": [\\"../*\\"],
|
||||
\\"@\\": [\\"../src\\"],
|
||||
\\"@/*\\": [\\"../src/*\\"],
|
||||
\\"~\\": [\\"../src\\"],
|
||||
\\"~/*\\": [\\"../src/*\\"]
|
||||
\\"~/*\\": [\\"../src/*\\"],
|
||||
\\"@@\\": [\\"..\\"],
|
||||
\\"@@/*\\": [\\"../*\\"],
|
||||
\\"~~\\": [\\"..\\"],
|
||||
\\"~~/*\\": [\\"../*\\"]
|
||||
}
|
||||
},
|
||||
\\"include\\": [
|
||||
|
||||
+13
-9
@@ -4,29 +4,33 @@ import glob from 'fast-glob';
|
||||
import { execaCommand } from 'execa';
|
||||
import { InlineConfig, UserConfig, build } from '../src';
|
||||
import { normalizePath } from '../src/core/utils/paths';
|
||||
import merge from 'lodash.merge';
|
||||
|
||||
export class TestProject {
|
||||
files: Array<[string, string]> = [];
|
||||
config: UserConfig | undefined;
|
||||
readonly root: string;
|
||||
|
||||
constructor(root = 'e2e/dist') {
|
||||
constructor(packageJson: any = {}) {
|
||||
// We can't put each test's project inside e2e/dist directly, otherwise the wxt.config.ts
|
||||
// file is cached and cannot be different between each test. Instead, we add a random ID to the
|
||||
// end to make each test's path unique.
|
||||
const id = Math.random().toString(32).substring(3);
|
||||
this.root = join(root, id);
|
||||
this.root = join('e2e/dist', id);
|
||||
this.files.push([
|
||||
'package.json',
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'E2E Extension',
|
||||
description: 'Example description',
|
||||
version: '0.0.0-test',
|
||||
dependencies: {
|
||||
wxt: '../../..',
|
||||
merge(
|
||||
{
|
||||
name: 'E2E Extension',
|
||||
description: 'Example description',
|
||||
version: '0.0.0-test',
|
||||
dependencies: {
|
||||
wxt: '../../..',
|
||||
},
|
||||
},
|
||||
},
|
||||
packageJson,
|
||||
),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
|
||||
+7
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wxt",
|
||||
"type": "module",
|
||||
"version": "0.6.4",
|
||||
"version": "0.7.1",
|
||||
"description": "Next gen framework for developing web extensions",
|
||||
"engines": {
|
||||
"node": ">=18.16.0",
|
||||
@@ -56,22 +56,24 @@
|
||||
"format:check": "prettier --write .",
|
||||
"compile": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage": "vitest run --coverage.enabled --coverage.exclude=e2e --coverage.exclude=src/testing",
|
||||
"prepare": "simple-git-hooks",
|
||||
"prepublish": "pnpm -s build",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs",
|
||||
"sync-releases": "pnpx changelogen@latest gh release all"
|
||||
"sync-releases": "pnpx changelogen@latest gh release"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/webextension-polyfill": "^0.10.1",
|
||||
"@webext-core/fake-browser": "^1.2.2",
|
||||
"@webext-core/match-patterns": "^1.0.1",
|
||||
"@webext-core/isolated-element": "^1.0.4",
|
||||
"@webext-core/match-patterns": "^1.0.2",
|
||||
"async-mutex": "^0.4.0",
|
||||
"c12": "^1.4.2",
|
||||
"cac": "^6.7.14",
|
||||
"consola": "^3.2.3",
|
||||
"esbuild": "^0.19.4",
|
||||
"fast-glob": "^3.3.1",
|
||||
"filesize": "^10.0.8",
|
||||
"fs-extra": "^11.1.1",
|
||||
@@ -101,6 +103,7 @@
|
||||
"@types/prompts": "^2.4.4",
|
||||
"@vitest/coverage-v8": "^0.34.1",
|
||||
"execa": "^7.2.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"lint-staged": "^14.0.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"npm-run-all": "^4.1.5",
|
||||
|
||||
Generated
+469
-144
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,336 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ContentScriptContext, createContentScriptUi } from '..';
|
||||
import { createIsolatedElement } from '@webext-core/isolated-element';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { browser } from '../browser';
|
||||
|
||||
vi.mock('webextension-polyfill', () =>
|
||||
vi.importActual('../virtual-modules/fake-browser'),
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
expect(
|
||||
document.querySelector('#parent > :nth-child(3)')?.tagName,
|
||||
).toEqual('TEST-APP');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import { createIsolatedElement } from '@webext-core/isolated-element';
|
||||
import { browser } from './browser';
|
||||
import { logger } from './utils/logger';
|
||||
import { ContentScriptContext } from '.';
|
||||
|
||||
/**
|
||||
* Utility for mounting content script UI's with isolated styles. Automatically removed from the DOM
|
||||
* when the content script's context is invalidated.
|
||||
*
|
||||
* See <https://wxt.dev/guide/content-scripts.html#ui> 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<T>(
|
||||
ctx: ContentScriptContext,
|
||||
options: ContentScriptUiOptions<T>,
|
||||
): Promise<ContentScriptUi<T>> {
|
||||
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',
|
||||
});
|
||||
|
||||
const getAnchor = (): 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;
|
||||
};
|
||||
|
||||
let mounted: T;
|
||||
|
||||
const mount = () => {
|
||||
const anchor = getAnchor();
|
||||
if (anchor == null)
|
||||
throw Error(
|
||||
'Failed to mount content script ui: could not find anchor element',
|
||||
);
|
||||
|
||||
// Mount UI inside shadow root
|
||||
mounted = options.mount(uiContainer);
|
||||
|
||||
// Add shadow root element to DOM
|
||||
switch (options.append) {
|
||||
case undefined:
|
||||
case 'last':
|
||||
anchor.append(shadowHost);
|
||||
break;
|
||||
case 'first':
|
||||
if (anchor.firstChild) {
|
||||
anchor.insertBefore(shadowHost, anchor.firstChild);
|
||||
} else {
|
||||
anchor.append(shadowHost);
|
||||
}
|
||||
break;
|
||||
case 'replace':
|
||||
anchor.replaceWith(shadowHost);
|
||||
break;
|
||||
case 'after':
|
||||
anchor.replaceWith(anchor, shadowHost);
|
||||
break;
|
||||
case 'before':
|
||||
anchor.replaceWith(shadowHost, anchor);
|
||||
break;
|
||||
default:
|
||||
options.append(anchor, shadowHost);
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply types
|
||||
if (options.type !== 'inline') {
|
||||
if (options.zIndex != null)
|
||||
shadowHost.style.zIndex = String(options.zIndex);
|
||||
|
||||
shadowHost.style.overflow = 'visible';
|
||||
shadowHost.style.position = 'relative';
|
||||
shadowHost.style.width = '0';
|
||||
shadowHost.style.height = '0';
|
||||
shadowHost.style.display = 'block';
|
||||
|
||||
const html = shadow.querySelector('html')!;
|
||||
// HTML doesn't exist in tests
|
||||
if (options.type === 'overlay') {
|
||||
html.style.position = 'absolute';
|
||||
if (options.alignment?.startsWith('bottom-')) html.style.bottom = '0';
|
||||
else html.style.top = '0';
|
||||
|
||||
if (options.alignment?.endsWith('-right')) html.style.right = '0';
|
||||
else html.style.left = '0';
|
||||
} else {
|
||||
html.style.position = 'fixed';
|
||||
html.style.top = '0';
|
||||
html.style.bottom = '0';
|
||||
html.style.left = '0';
|
||||
html.style.right = '0';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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<T> {
|
||||
/**
|
||||
* 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: T;
|
||||
/**
|
||||
* Function that mounts or remounts the UI on the page.
|
||||
*/
|
||||
mount: () => void;
|
||||
/**
|
||||
* Function that removes the UI from the webpage.
|
||||
*/
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
interface BaseContentScriptUiOptions<T> {
|
||||
/**
|
||||
* The name of the custom component used to host the ShadowRoot. Must be kebab-case.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* 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);
|
||||
/**
|
||||
* 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);
|
||||
/**
|
||||
* 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) => T;
|
||||
/**
|
||||
* Callback called when the UI is removed from the webpage. Use to cleanup your UI, like
|
||||
* unmounting your vue or react apps.
|
||||
*/
|
||||
onRemove?: (mounted: T) => 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-scripts.html#ui> for more info.
|
||||
*/
|
||||
css?: string;
|
||||
}
|
||||
|
||||
export type OverlayContentScriptUiOptions<T> = BaseContentScriptUiOptions<T> & {
|
||||
type: 'overlay';
|
||||
/**
|
||||
* 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?: ContentScriptUiOverlayAlignment;
|
||||
/**
|
||||
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
|
||||
* content.
|
||||
*/
|
||||
zIndex?: number;
|
||||
};
|
||||
|
||||
export type ModalContentScriptUiOptions<T> = BaseContentScriptUiOptions<T> & {
|
||||
type: 'modal';
|
||||
/**
|
||||
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
|
||||
* content.
|
||||
*/
|
||||
zIndex?: number;
|
||||
};
|
||||
|
||||
export type InlineContentScriptUiOptions<T> = BaseContentScriptUiOptions<T> & {
|
||||
type: 'inline';
|
||||
};
|
||||
|
||||
export type ContentScriptUiOverlayAlignment =
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right';
|
||||
|
||||
export type ContentScriptAppendMode =
|
||||
| 'last'
|
||||
| 'first'
|
||||
| 'replace'
|
||||
| 'before'
|
||||
| 'after';
|
||||
|
||||
export type ContentScriptUiOptions<T> =
|
||||
| OverlayContentScriptUiOptions<T>
|
||||
| ModalContentScriptUiOptions<T>
|
||||
| InlineContentScriptUiOptions<T>;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export * from './defineContentScript';
|
||||
export * from './defineBackground';
|
||||
export * from './mountContentScriptUi';
|
||||
export * from './createContentScriptUi';
|
||||
export * from './utils/ContentScriptContext';
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function mountContentScriptUi() {
|
||||
throw Error('Not implemented: mountContentScriptUi');
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ContentScriptDefinition } from '../../core/types';
|
||||
import { browser } from '../browser';
|
||||
import { logger } from './logger';
|
||||
|
||||
@@ -13,7 +14,10 @@ export class ContentScriptContext extends AbortController {
|
||||
|
||||
#isTopFrame = window.self === window.top;
|
||||
|
||||
constructor(private readonly contentScriptName: string) {
|
||||
constructor(
|
||||
private readonly contentScriptName: string,
|
||||
public readonly options?: Omit<ContentScriptDefinition, 'main'>,
|
||||
) {
|
||||
super();
|
||||
|
||||
if (this.#isTopFrame) {
|
||||
@@ -157,10 +161,13 @@ export class ContentScriptContext extends AbortController {
|
||||
|
||||
#stopOldScripts() {
|
||||
// Use postMessage so it get's sent to all the frames of the page.
|
||||
window.postMessage({
|
||||
event: ContentScriptContext.SCRIPT_STARTED_MESSAGE_TYPE,
|
||||
contentScriptName: this.contentScriptName,
|
||||
});
|
||||
window.postMessage(
|
||||
{
|
||||
event: ContentScriptContext.SCRIPT_STARTED_MESSAGE_TYPE,
|
||||
contentScriptName: this.contentScriptName,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
#listenForNewerScripts() {
|
||||
|
||||
@@ -4,9 +4,10 @@ import { ContentScriptContext } from '../utils/ContentScriptContext';
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const ctx = new ContentScriptContext(__ENTRYPOINT__);
|
||||
const { main, ...options } = definition;
|
||||
const ctx = new ContentScriptContext(__ENTRYPOINT__, options);
|
||||
|
||||
await definition.main(ctx);
|
||||
await main(ctx);
|
||||
} catch (err) {
|
||||
logger.error('The content script crashed on startup!', err);
|
||||
}
|
||||
|
||||
@@ -84,10 +84,15 @@ async function buildSingleEntrypoint(
|
||||
config.outDir,
|
||||
'.js',
|
||||
),
|
||||
// Output content script CSS to assets/ with a hash to prevent conflicts. Defaults to
|
||||
// "[name].[ext]" in lib mode, which usually results in "style.css". That means multiple
|
||||
// content scripts with styles would overwrite each other if it weren't changed below.
|
||||
assetFileNames: `assets/${entrypoint.name}.[ext]`,
|
||||
// Output content script CSS to `content-scripts/`, but all other scripts are written to
|
||||
// `assets/`.
|
||||
assetFileNames: ({ name }) => {
|
||||
if (entrypoint.type === 'content-script' && name?.endsWith('css')) {
|
||||
return `content-scripts/${entrypoint.name}.[ext]`;
|
||||
} else {
|
||||
return `assets/${entrypoint.name}.[ext]`;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -213,14 +213,14 @@ async function writeTsConfigFile(
|
||||
"lib": ["DOM", "WebWorker"],
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@@": ["${rootPath}"],
|
||||
"@@/*": ["${rootPath}/*"],
|
||||
"~~": ["${rootPath}"],
|
||||
"~~/*": ["${rootPath}/*"],
|
||||
"@": ["${srcPath}"],
|
||||
"@/*": ["${srcPath}/*"],
|
||||
"~": ["${srcPath}"],
|
||||
"~/*": ["${srcPath}/*"]
|
||||
"~/*": ["${srcPath}/*"],
|
||||
"@@": ["${rootPath}"],
|
||||
"@@/*": ["${rootPath}/*"],
|
||||
"~~": ["${rootPath}"],
|
||||
"~~/*": ["${rootPath}/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createExtensionRunner } from '..';
|
||||
import { fakeInternalConfig } from '../../../testing/fake-objects';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { createSafariRunner } from '../safari';
|
||||
import { ExtensionRunner } from '../extension-runner';
|
||||
import { createWslRunner } from '../wsl';
|
||||
import { createManualRunner } from '../manual';
|
||||
import { isWsl } from '../../utils/wsl';
|
||||
import { createWebExtRunner } from '../web-ext';
|
||||
|
||||
vi.mock('../../utils/wsl');
|
||||
const isWslMock = vi.mocked(isWsl);
|
||||
|
||||
vi.mock('../safari');
|
||||
const createSafariRunnerMock = vi.mocked(createSafariRunner);
|
||||
|
||||
vi.mock('../wsl');
|
||||
const createWslRunnerMock = vi.mocked(createWslRunner);
|
||||
|
||||
vi.mock('../manual');
|
||||
const createManualRunnerMock = vi.mocked(createManualRunner);
|
||||
|
||||
vi.mock('../web-ext');
|
||||
const createWebExtRunnerMock = vi.mocked(createWebExtRunner);
|
||||
|
||||
describe('createExtensionRunner', () => {
|
||||
it('should return a Safari runner when browser is "safari"', async () => {
|
||||
const config = fakeInternalConfig({
|
||||
browser: 'safari',
|
||||
});
|
||||
const safariRunner = mock<ExtensionRunner>();
|
||||
createSafariRunnerMock.mockReturnValue(safariRunner);
|
||||
|
||||
await expect(createExtensionRunner(config)).resolves.toBe(safariRunner);
|
||||
});
|
||||
|
||||
it('should return a WSL runner when `is-wsl` is true', async () => {
|
||||
isWslMock.mockResolvedValueOnce(true);
|
||||
const config = fakeInternalConfig({
|
||||
browser: 'chrome',
|
||||
});
|
||||
const wslRunner = mock<ExtensionRunner>();
|
||||
createWslRunnerMock.mockReturnValue(wslRunner);
|
||||
|
||||
await expect(createExtensionRunner(config)).resolves.toBe(wslRunner);
|
||||
});
|
||||
|
||||
it('should return a manual runner when `runner.disabled` is true', async () => {
|
||||
isWslMock.mockResolvedValueOnce(false);
|
||||
const config = fakeInternalConfig({
|
||||
browser: 'chrome',
|
||||
runnerConfig: {
|
||||
config: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const manualRunner = mock<ExtensionRunner>();
|
||||
createManualRunnerMock.mockReturnValue(manualRunner);
|
||||
|
||||
await expect(createExtensionRunner(config)).resolves.toBe(manualRunner);
|
||||
});
|
||||
|
||||
it('should return a web-ext runner otherwise', async () => {
|
||||
const config = fakeInternalConfig({
|
||||
browser: 'chrome',
|
||||
runnerConfig: {
|
||||
config: {
|
||||
disabled: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
const manualRunner = mock<ExtensionRunner>();
|
||||
createWebExtRunnerMock.mockReturnValue(manualRunner);
|
||||
|
||||
await expect(createExtensionRunner(config)).resolves.toBe(manualRunner);
|
||||
});
|
||||
});
|
||||
@@ -3,14 +3,16 @@ import { ExtensionRunner } from './extension-runner';
|
||||
import { createWslRunner } from './wsl';
|
||||
import { createWebExtRunner } from './web-ext';
|
||||
import { createSafariRunner } from './safari';
|
||||
import { createManualRunner } from './manual';
|
||||
import { isWsl } from '../utils/wsl';
|
||||
|
||||
export async function createExtensionRunner(
|
||||
config: InternalConfig,
|
||||
): Promise<ExtensionRunner> {
|
||||
if (config.browser === 'safari') return createSafariRunner();
|
||||
|
||||
const { default: isWsl } = await import('is-wsl'); // ESM only, requires dynamic import
|
||||
if (isWsl) return createWslRunner();
|
||||
if (await isWsl()) return createWslRunner();
|
||||
if (config.runnerConfig.config?.disabled) return createManualRunner();
|
||||
|
||||
return createWebExtRunner();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ExtensionRunner } from './extension-runner';
|
||||
import { relative } from 'node:path';
|
||||
|
||||
/**
|
||||
* The manual runner tells the user to load the unpacked extension manually.
|
||||
*/
|
||||
export function createManualRunner(): ExtensionRunner {
|
||||
return {
|
||||
async openBrowser(config) {
|
||||
config.logger.info(
|
||||
`Load "${relative(
|
||||
process.cwd(),
|
||||
config.outDir,
|
||||
)}" as an unpacked extension manually`,
|
||||
);
|
||||
},
|
||||
async closeBrowser() {
|
||||
// noop
|
||||
},
|
||||
};
|
||||
}
|
||||
+12
-5
@@ -7,7 +7,10 @@ import {
|
||||
import * as vite from 'vite';
|
||||
import { Scripting } from 'webextension-polyfill';
|
||||
import { getEntrypointBundlePath } from './utils/entrypoints';
|
||||
import { getContentScriptCssFiles } from './utils/manifest';
|
||||
import {
|
||||
getContentScriptCssFiles,
|
||||
getContentScriptsCssMap,
|
||||
} from './utils/manifest';
|
||||
import { buildInternal } from './build';
|
||||
import { createExtensionRunner } from './runners';
|
||||
|
||||
@@ -99,12 +102,16 @@ export function reloadContentScripts(
|
||||
if (Array.isArray(entry) || entry.type !== 'content-script') return;
|
||||
|
||||
const js = [getEntrypointBundlePath(entry, config.outDir, '.js')];
|
||||
const css = getContentScriptCssFiles([entry], server.currentOutput);
|
||||
|
||||
const { include: _1, exclude: _2, ...options } = entry.options;
|
||||
const cssMap = getContentScriptsCssMap(server.currentOutput, [entry]);
|
||||
const css = getContentScriptCssFiles([entry], cssMap);
|
||||
|
||||
server.reloadContentScript({
|
||||
...options,
|
||||
allFrames: entry.options.allFrames,
|
||||
excludeMatches: entry.options.excludeMatches,
|
||||
matches: entry.options.matches,
|
||||
runAt: entry.options.runAt,
|
||||
// @ts-expect-error: Chrome accepts this, not typed in webextension-polyfill (https://developer.chrome.com/docs/extensions/reference/scripting/#type-RegisteredContentScript)
|
||||
world: entry.options.world,
|
||||
js,
|
||||
css,
|
||||
});
|
||||
|
||||
@@ -177,6 +177,8 @@ export interface InlineConfig {
|
||||
/**
|
||||
* Explicitly include bundle analysis when running `wxt build`. This can be overridden by the
|
||||
* command line `--analysis` option.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
@@ -184,6 +186,8 @@ export interface InlineConfig {
|
||||
* bundle will be visualized. See
|
||||
* [`rollup-plugin-visualizer`](https://github.com/btd/rollup-plugin-visualizer#how-to-use-generated-files)
|
||||
* for more details.
|
||||
*
|
||||
* @default "treemap"
|
||||
*/
|
||||
template?: PluginVisualizerOptions['template'];
|
||||
};
|
||||
@@ -402,6 +406,18 @@ export interface ContentScriptDefinition extends ExcludableEntrypoint {
|
||||
* @default "ISOLATED"
|
||||
*/
|
||||
world?: 'ISOLATED' | 'MAIN';
|
||||
/**
|
||||
* Customize how imported/generated styles are injected with the content script. Regardless of the
|
||||
* mode selected, CSS will always be built and included in the output directory.
|
||||
*
|
||||
* - `"manifest"` - Include the CSS in the manifest, under the content script's `css` array.
|
||||
* - `"manual"` - Exclude the CSS from the manifest. You are responsible for manually loading it
|
||||
* onto the page. Use `browser.runtime.getURL("content-scripts/<name>.css")` to get the file's
|
||||
* URL
|
||||
*
|
||||
* @default "manifest"
|
||||
*/
|
||||
cssInjectionMode?: 'manifest' | 'manual' | 'ui';
|
||||
/**
|
||||
* Main function executed when the content script is loaded.
|
||||
*/
|
||||
@@ -474,6 +490,12 @@ export interface ConfigEnv {
|
||||
* Configure how the browser starts up.
|
||||
*/
|
||||
export interface ExtensionRunnerConfig {
|
||||
/**
|
||||
* Whether or not to open the browser with the extension installed in dev mode.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @see https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#browser-console
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
UserManifest,
|
||||
ExtensionRunnerConfig,
|
||||
ConfigEnv,
|
||||
UserConfig,
|
||||
} from './external';
|
||||
import { UnimportOptions } from 'unimport';
|
||||
import { ResolvedConfig } from 'c12';
|
||||
@@ -48,6 +49,7 @@ export interface InternalConfig {
|
||||
enabled: boolean;
|
||||
template: NonNullable<PluginVisualizerOptions['template']>;
|
||||
};
|
||||
userConfigMetadata: Omit<ResolvedConfig<UserConfig>, 'config'>;
|
||||
}
|
||||
|
||||
export type EntrypointGroup = Entrypoint | Entrypoint[];
|
||||
|
||||
@@ -30,13 +30,15 @@ export async function getInternalConfig(
|
||||
// Load user config
|
||||
|
||||
let userConfig: UserConfig = {};
|
||||
let userConfigMetadata: InternalConfig['userConfigMetadata'] | undefined;
|
||||
if (inlineConfig.configFile !== false) {
|
||||
const loaded = await loadConfig<UserConfig>({
|
||||
const { config: loadedConfig, ...metadata } = await loadConfig<UserConfig>({
|
||||
name: 'wxt',
|
||||
cwd: inlineConfig.root ?? process.cwd(),
|
||||
rcFile: false,
|
||||
});
|
||||
userConfig = loaded.config ?? {};
|
||||
userConfig = loadedConfig ?? {};
|
||||
userConfigMetadata = metadata;
|
||||
}
|
||||
|
||||
// Merge it into the inline config
|
||||
@@ -75,7 +77,8 @@ export async function getInternalConfig(
|
||||
cwd: root,
|
||||
globalRc: true,
|
||||
rcFile: '.webextrc',
|
||||
overrides: mergedConfig.runner,
|
||||
overrides: inlineConfig.runner,
|
||||
defaults: userConfig.runner,
|
||||
});
|
||||
|
||||
const finalConfig: InternalConfig = {
|
||||
@@ -108,6 +111,7 @@ export async function getInternalConfig(
|
||||
enabled: mergedConfig.analysis?.enabled ?? false,
|
||||
template: mergedConfig.analysis?.template ?? 'treemap',
|
||||
},
|
||||
userConfigMetadata: userConfigMetadata ?? {},
|
||||
};
|
||||
|
||||
finalConfig.vite = (env) =>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import createJITI from 'jiti';
|
||||
import createJITI, { TransformOptions as JitiTransformOptions } from 'jiti';
|
||||
import { InternalConfig } from '../types';
|
||||
import { createUnimport } from 'unimport';
|
||||
import fs from 'fs-extra';
|
||||
import { resolve } from 'path';
|
||||
import transform from 'jiti/dist/babel';
|
||||
import { getUnimportOptions } from './auto-imports';
|
||||
import { removeProjectImportStatements } from './strings';
|
||||
import { normalizePath } from './paths';
|
||||
import { TransformOptions, transformSync } from 'esbuild';
|
||||
|
||||
/**
|
||||
* Get the value from the default export of a `path`.
|
||||
@@ -47,6 +47,7 @@ export async function importEntrypointFile<T>(
|
||||
|
||||
const jiti = createJITI(__filename, {
|
||||
cache: false,
|
||||
debug: config.debug,
|
||||
esmResolve: true,
|
||||
interopDefault: true,
|
||||
alias: {
|
||||
@@ -55,10 +56,14 @@ export async function importEntrypointFile<T>(
|
||||
'node_modules/wxt/dist/virtual-modules/fake-browser.js',
|
||||
),
|
||||
},
|
||||
extensions: ['.ts', '.tsx', '.cjs', '.js', '.mjs'],
|
||||
transform(opts) {
|
||||
if (opts.filename === normalPath)
|
||||
return transform({ ...opts, source: code });
|
||||
else return transform(opts);
|
||||
const isEntrypoint = opts.filename === normalPath;
|
||||
return transformSync(
|
||||
// Use modified source code for entrypoints
|
||||
isEntrypoint ? code : opts.source,
|
||||
getEsbuildOptions(opts),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -69,3 +74,12 @@ export async function importEntrypointFile<T>(
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function getEsbuildOptions(opts: JitiTransformOptions): TransformOptions {
|
||||
const isJsx = opts.filename?.endsWith('x');
|
||||
return {
|
||||
format: 'cjs',
|
||||
loader: isJsx ? 'tsx' : 'ts',
|
||||
jsx: isJsx ? 'automatic' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
+98
-29
@@ -20,6 +20,7 @@ import { getPackageJson } from './package';
|
||||
import { normalizePath } from './paths';
|
||||
import { writeFileIfDifferent } from './fs';
|
||||
import { produce } from 'immer';
|
||||
import * as vite from 'vite';
|
||||
|
||||
/**
|
||||
* Writes the manifest to the output directory and the build output.
|
||||
@@ -56,41 +57,44 @@ export async function generateMainfest(
|
||||
): Promise<Manifest.WebExtensionManifest> {
|
||||
const pkg = await getPackageJson(config);
|
||||
|
||||
const manifest: Manifest.WebExtensionManifest = Object.assign(
|
||||
{
|
||||
manifest_version: config.manifestVersion,
|
||||
name: pkg?.name,
|
||||
description: pkg?.description,
|
||||
version: pkg?.version && simplifyVersion(pkg.version),
|
||||
// Only add the version name to chromium and if the user hasn't specified a custom version.
|
||||
version_name:
|
||||
config.browser !== 'firefox' && !config.manifest.version
|
||||
? pkg?.version
|
||||
: undefined,
|
||||
short_name: pkg?.shortName,
|
||||
icons: discoverIcons(buildOutput),
|
||||
},
|
||||
config.manifest,
|
||||
);
|
||||
const baseManifest: Manifest.WebExtensionManifest = {
|
||||
manifest_version: config.manifestVersion,
|
||||
name: pkg?.name,
|
||||
description: pkg?.description,
|
||||
version: pkg?.version && simplifyVersion(pkg.version),
|
||||
// Only add the version name to chromium and if the user hasn't specified a custom version.
|
||||
version_name:
|
||||
config.browser !== 'firefox' && !config.manifest.version
|
||||
? pkg?.version
|
||||
: undefined,
|
||||
short_name: pkg?.shortName,
|
||||
icons: discoverIcons(buildOutput),
|
||||
};
|
||||
const userManifest = config.manifest;
|
||||
|
||||
const manifest = vite.mergeConfig(
|
||||
baseManifest,
|
||||
userManifest,
|
||||
) as Manifest.WebExtensionManifest;
|
||||
|
||||
addEntrypoints(manifest, entrypoints, buildOutput, config);
|
||||
|
||||
if (config.command === 'serve') addDevModeCsp(manifest, config);
|
||||
if (config.command === 'serve') addDevModePermissions(manifest, config);
|
||||
|
||||
// TODO: transform manifest here.
|
||||
const finalManifest = produce(manifest, config.transformManifest);
|
||||
|
||||
if (manifest.name == null)
|
||||
if (finalManifest.name == null)
|
||||
throw Error(
|
||||
"Manifest 'name' is missing. Either:\n1. Set the name in your <rootDir>/package.json\n2. Set a name via the manifest option in your wxt.config.ts",
|
||||
);
|
||||
if (manifest.version == null) {
|
||||
if (finalManifest.version == null) {
|
||||
throw Error(
|
||||
"Manifest 'version' is missing. Either:\n1. Add a version in your <rootDir>/package.json\n2. Pass the version via the manifest option in your wxt.config.ts",
|
||||
);
|
||||
}
|
||||
|
||||
return produce(manifest, config.transformManifest);
|
||||
return finalManifest;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,6 +288,8 @@ function addEntrypoints(
|
||||
}
|
||||
|
||||
if (contentScripts?.length) {
|
||||
const cssMap = getContentScriptsCssMap(buildOutput, contentScripts);
|
||||
|
||||
// Don't add content scripts to the manifest in dev mode for MV3 - they're managed and reloaded
|
||||
// at runtime
|
||||
if (config.command === 'serve' && config.manifestVersion === 3) {
|
||||
@@ -309,7 +315,7 @@ function addEntrypoints(
|
||||
...mapWxtOptionsToContentScript(scripts[0].options),
|
||||
// TOOD: Sorting css and js arrays here so we get consistent test results... but we
|
||||
// shouldn't have to. Where is the inconsistency coming from?
|
||||
css: getContentScriptCssFiles(scripts, buildOutput)?.sort(),
|
||||
css: getContentScriptCssFiles(scripts, cssMap)?.sort(),
|
||||
js: scripts
|
||||
.map((entry) =>
|
||||
getEntrypointBundlePath(entry, config.outDir, '.js'),
|
||||
@@ -322,6 +328,16 @@ function addEntrypoints(
|
||||
manifest.content_scripts.push(...newContentScripts);
|
||||
}
|
||||
}
|
||||
|
||||
const contentScriptCssResources = getContentScriptCssWebAccessibleResources(
|
||||
config,
|
||||
contentScripts,
|
||||
cssMap,
|
||||
);
|
||||
if (contentScriptCssResources.length > 0) {
|
||||
manifest.web_accessible_resources ??= [];
|
||||
manifest.web_accessible_resources.push(...contentScriptCssResources);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,24 +425,77 @@ function addDevModePermissions(
|
||||
*/
|
||||
export function getContentScriptCssFiles(
|
||||
contentScripts: ContentScriptEntrypoint[],
|
||||
buildOutput: Omit<BuildOutput, 'manifest'>,
|
||||
contentScriptCssMap: Record<string, string | undefined>,
|
||||
): string[] | undefined {
|
||||
const css: string[] = [];
|
||||
|
||||
const allChunks = buildOutput.steps.flatMap((step) => step.chunks);
|
||||
|
||||
contentScripts.forEach((script) => {
|
||||
// TODO: optimize and remove loop with a map
|
||||
const relatedCss = allChunks.find(
|
||||
(chunk) => chunk.fileName === `assets/${script.name}.css`,
|
||||
);
|
||||
if (relatedCss) css.push(relatedCss.fileName);
|
||||
if (
|
||||
script.options.cssInjectionMode === 'manual' ||
|
||||
script.options.cssInjectionMode === 'ui'
|
||||
)
|
||||
return;
|
||||
|
||||
const cssFile = contentScriptCssMap[script.name];
|
||||
if (cssFile == null) return;
|
||||
|
||||
if (cssFile) css.push(cssFile);
|
||||
});
|
||||
|
||||
if (css.length > 0) return css;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content scripts configured with `cssInjectionMode: "ui"` need to add their CSS files to web
|
||||
* accessible resources so they can be fetched as text and added to shadow roots that the UI is
|
||||
* added to.
|
||||
*/
|
||||
export function getContentScriptCssWebAccessibleResources(
|
||||
config: InternalConfig,
|
||||
contentScripts: ContentScriptEntrypoint[],
|
||||
contentScriptCssMap: Record<string, string | undefined>,
|
||||
): any[] {
|
||||
const resources: any[] = [];
|
||||
|
||||
contentScripts.forEach((script) => {
|
||||
if (script.options.cssInjectionMode !== 'ui') return;
|
||||
|
||||
const cssFile = contentScriptCssMap[script.name];
|
||||
if (cssFile == null) return;
|
||||
|
||||
if (config.manifestVersion === 2) {
|
||||
resources.push(cssFile);
|
||||
} else {
|
||||
resources.push({
|
||||
resources: [cssFile],
|
||||
matches: script.options.matches,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on the build output, return a Record of each content script's name to it CSS file if the
|
||||
* script includes one.
|
||||
*/
|
||||
export function getContentScriptsCssMap(
|
||||
buildOutput: Omit<BuildOutput, 'manifest'>,
|
||||
scripts: ContentScriptEntrypoint[],
|
||||
) {
|
||||
const map: Record<string, string | undefined> = {};
|
||||
const allChunks = buildOutput.steps.flatMap((step) => step.chunks);
|
||||
scripts.forEach((script) => {
|
||||
const relatedCss = allChunks.find(
|
||||
(chunk) => chunk.fileName === `content-scripts/${script.name}.css`,
|
||||
);
|
||||
if (relatedCss != null) map[script.name] = relatedCss.fileName;
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function addPermission(
|
||||
manifest: Manifest.WebExtensionManifest,
|
||||
permission: string,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Returns true when running on WSL or WSL2.
|
||||
*/
|
||||
export async function isWsl(): Promise<boolean> {
|
||||
const { default: isWsl } = await import('is-wsl'); // ESM only, requires dynamic import
|
||||
return isWsl;
|
||||
}
|
||||
@@ -240,5 +240,6 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
|
||||
name: faker.person.firstName().toLowerCase(),
|
||||
},
|
||||
transformManifest: () => {},
|
||||
userConfigMetadata: {},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ node_modules
|
||||
.output
|
||||
stats.html
|
||||
.wxt
|
||||
web-ext.config.ts
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -11,6 +11,7 @@ node_modules
|
||||
.output
|
||||
stats.html
|
||||
.wxt
|
||||
web-ext.config.ts
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -11,6 +11,7 @@ node_modules
|
||||
.output
|
||||
stats.html
|
||||
.wxt
|
||||
web-ext.config.ts
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -11,6 +11,7 @@ node_modules
|
||||
.output
|
||||
stats.html
|
||||
.wxt
|
||||
web-ext.config.ts
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -11,6 +11,7 @@ node_modules
|
||||
.output
|
||||
stats.html
|
||||
.wxt
|
||||
web-ext.config.ts
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// @ts-expect-error
|
||||
globalThis.__ENTRYPOINT__ = 'test';
|
||||
@@ -25,6 +25,7 @@ export default defineWorkspace([
|
||||
dir: 'src',
|
||||
mockReset: true,
|
||||
restoreMocks: true,
|
||||
setupFiles: 'vitest.setup.ts',
|
||||
},
|
||||
plugins: [testSeed()],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user