Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a643852387 | |||
| e221252325 | |||
| 88f126e658 | |||
| 17af05acc8 | |||
| fd436a0d63 | |||
| 0d796b63c2 | |||
| cb143d2bbc | |||
| f3e7758521 | |||
| 23ef101954 | |||
| c2f5efbde4 | |||
| 4ce178c592 | |||
| 7aa30afa7f | |||
| 90aaed202c | |||
| c92ed13d66 | |||
| 0e4953fbfc | |||
| 799d687061 | |||
| 866eb0694c | |||
| e5723aab03 | |||
| f51f5dd19f | |||
| aae5f0789d | |||
| 586fe3950c | |||
| 241c907c65 | |||
| aab4244d84 | |||
| 767a3a987c | |||
| a2924d872a | |||
| 54baf21490 | |||
| 5a3790a7d3 | |||
| 236e099b81 | |||
| 9b54bc4704 | |||
| ffb99af719 | |||
| 90bb145d91 | |||
| 190c87d120 | |||
| 4c511d5728 | |||
| b8ecbc5a54 | |||
| 8c3d756187 | |||
| cfdc6b3e6b | |||
| 226e6c9fbe | |||
| db41f27072 | |||
| db025cd3a2 | |||
| 57229c69c1 | |||
| 8f3a90eeb2 | |||
| a360965d92 | |||
| eb1ace6d20 | |||
| 497c902283 | |||
| 4d4453b830 | |||
| ef53580ed1 | |||
| 52e5387d47 | |||
| f5b7f7e0e5 | |||
| 592bdbe4d9 | |||
| 6fc227bad6 | |||
| d74f6b0735 | |||
| 62ea796045 | |||
| 064704c9b7 | |||
| c846a1febf | |||
| c3959e8427 | |||
| e770f9b6b7 | |||
| 67f35615a2 | |||
| 5c7b6098a2 | |||
| f7d12b7486 | |||
| 3481313859 | |||
| 2f23556893 | |||
| 330e39159a | |||
| aa14676f91 | |||
| 199f330324 | |||
| 63dee97ac3 | |||
| b121ed2fe2 | |||
| 607e1162e5 | |||
| b4d569a915 | |||
| 4fac364542 | |||
| 51c7ee5fa8 | |||
| 9b780f2d00 | |||
| f5aec7ec9b | |||
| 5d991eda31 | |||
| 0318a34046 | |||
| e7c66304d3 | |||
| 4603ebb511 | |||
| ea570c12b7 | |||
| c17ce34a27 | |||
| 0e4d3ad8ab | |||
| 4304f71df2 | |||
| bb5ea34396 | |||
| c30adb409c | |||
| ab83031462 | |||
| cd7285c3ef | |||
| bdb775c88d | |||
| d20793d5e6 | |||
| ae1e276577 | |||
| c031c6e82e | |||
| d06f8128f5 |
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [wxt-dev] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -4,6 +4,9 @@ inputs:
|
||||
install:
|
||||
default: 'true'
|
||||
description: Whether or not to run 'pnpm install'
|
||||
installArgs:
|
||||
default: ''
|
||||
description: Additional args to append to "pnpm install"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
@@ -17,4 +20,4 @@ runs:
|
||||
- name: Install Dependencies
|
||||
if: ${{ inputs.install == 'true' }}
|
||||
shell: bash
|
||||
run: pnpm install
|
||||
run: pnpm install ${{ inputs.installArgs }}
|
||||
|
||||
@@ -13,6 +13,7 @@ on:
|
||||
- module-solid
|
||||
- module-svelte
|
||||
- module-vue
|
||||
- storage
|
||||
- unocss
|
||||
- wxt
|
||||
|
||||
|
||||
@@ -7,13 +7,14 @@ on:
|
||||
default: wxt
|
||||
type: choice
|
||||
options:
|
||||
- wxt
|
||||
- module-react
|
||||
- module-vue
|
||||
- module-svelte
|
||||
- module-solid
|
||||
- auto-icons
|
||||
- i18n
|
||||
- module-react
|
||||
- module-solid
|
||||
- module-svelte
|
||||
- module-vue
|
||||
- storage
|
||||
- wxt
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
@@ -21,6 +22,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
with:
|
||||
installArgs: --ignore-scripts
|
||||
- run: pnpm tsx scripts/sync-releases.ts ${{ inputs.package }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- name: pnpm test:coverage
|
||||
run: pnpm test:coverage -- --reporter=default --reporter=hanging-process
|
||||
- uses: codecov/codecov-action@v4
|
||||
- uses: codecov/codecov-action@v5
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
windows-tests:
|
||||
|
||||
@@ -32,6 +32,7 @@ Here are some helpful commands:
|
||||
|
||||
```sh
|
||||
# Build WXT package
|
||||
cd packages/wxt
|
||||
pnpm build
|
||||
```
|
||||
|
||||
|
||||
@@ -50,6 +50,16 @@ const chromeExtensionIds = [
|
||||
'fcphghnknhkimeagdglkljinmpbagone', // YouTube Auto HD + FPS
|
||||
'lpomjgbicdemjkgmbnkjncgdebogkhlb', // MultiViewer Companion
|
||||
'ggiafipgeeaaahnjamgpjcgkdpanhddg', // Sync Watch - Watch videos together on any site
|
||||
'nmldnjcblcihmegipecakhmnieiofmgl', // Keyword Rank Checker
|
||||
'gppllamhaciichleihemgilcpledblpn', // YouTube Simple View - Hide distractions & more
|
||||
'pccbghdfdnnkkbcdcibchpbffdgednkf', // Propbar - Property Data Enhancer
|
||||
'lfknakglefggmdkjdfhhofkjnnolffkh', // Text Search Pro - Search by case and whole-word match!
|
||||
'mbenhbocjckkbaojacmaepiameldglij', // Invoice Generator
|
||||
'phlfhkmdofajnbhgmbmjkbkdgppgoppb', // Monthly Bill Tracker
|
||||
'macmkmchfoclhpbncclinhjflmdkaoom', // Wandpen - Instantly improve your writing with AI
|
||||
'lhmgechokhmdekdpgkkemoeecelcaonm', // YouTube Hider - Remove Comments By Keywords, Usernames & Tools
|
||||
'imgheieooppmahcgniieddodaliodeeg', // QA Compass - Record standardized bug reports easily
|
||||
'npgghjedpchajflknnbngajkjkdhncdo', // aesthetic Notion, styled
|
||||
];
|
||||
|
||||
const { data, err, isLoading } = useListExtensionDetails(chromeExtensionIds);
|
||||
|
||||
@@ -8,15 +8,17 @@ import {
|
||||
prepareTypedocSidebar,
|
||||
} from './utils/menus';
|
||||
import { meta, script } from './utils/head';
|
||||
import footnote from 'markdown-it-footnote';
|
||||
import { version as wxtVersion } from '../../packages/wxt/package.json';
|
||||
import { version as i18nVersion } from '../../packages/i18n/package.json';
|
||||
import { version as autoIconsVersion } from '../../packages/auto-icons/package.json';
|
||||
import { version as unocssVersion } from '../../packages/unocss/package.json';
|
||||
import { version as storageVersion } from '../../packages/storage/package.json';
|
||||
|
||||
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 utilities for building, zipping, and publishing your extension, it's easy to get started.";
|
||||
"WXT provides the best developer experience, making it quick, easy, and fun to develop web extensions. With built-in utilities 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';
|
||||
@@ -47,6 +49,12 @@ export default defineConfig({
|
||||
}),
|
||||
],
|
||||
|
||||
markdown: {
|
||||
config: (md) => {
|
||||
md.use(footnote);
|
||||
},
|
||||
},
|
||||
|
||||
themeConfig: {
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
logo: {
|
||||
@@ -80,7 +88,7 @@ export default defineConfig({
|
||||
),
|
||||
]),
|
||||
navItem('Other Packages', [
|
||||
navItem(`wxt/storage — ${wxtVersion}`, '/storage'),
|
||||
navItem(`@wxt-dev/storage — ${storageVersion}`, '/storage'),
|
||||
navItem(`@wxt-dev/auto-icons — ${autoIconsVersion}`, '/auto-icons'),
|
||||
navItem(`@wxt-dev/i18n — ${i18nVersion}`, '/i18n'),
|
||||
navItem(`@wxt-dev/unocss — ${unocssVersion}`, '/unocss'),
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 164 KiB |
@@ -62,7 +62,8 @@ import { resolve } from 'node:path';
|
||||
|
||||
export default defineRunnerConfig({
|
||||
// On Windows, the path must be absolute
|
||||
chromiumArgs: [`--user-data-dir="${resolve(".wxt/chrome-data")}"`
|
||||
chromiumProfile: resolve('.wxt/chrome-data'),
|
||||
keepProfileChanges: true,
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ WXT supports [dotenv files the same way as Vite](https://vite.dev/guide/env-and-
|
||||
.env.local
|
||||
.env.[mode]
|
||||
.env.[mode].local
|
||||
.env.[browser]
|
||||
.env.[browser].local
|
||||
.env.[mode].[browser]
|
||||
.env.[mode].[browser].local
|
||||
```
|
||||
|
||||
And any environment variables listed inside them will be available at runtime:
|
||||
@@ -52,3 +56,26 @@ Vite provides two other environment variables, but they aren't useful in WXT pro
|
||||
- `import.meta.env.BASE_URL`: Use `browser.runtime.getURL` instead.
|
||||
- `import.meta.env.SSR`: Always `false`.
|
||||
:::
|
||||
|
||||
## Manifest
|
||||
|
||||
To use environment variables in the manifest, you need to use the function syntax:
|
||||
|
||||
```ts
|
||||
export default defineConfig({
|
||||
extensionApi: 'chrome',
|
||||
modules: ['@wxt-dev/module-vue'],
|
||||
manifest: { // [!code --]
|
||||
oauth2: { // [!code --]
|
||||
client_id: import.meta.env.WXT_APP_CLIENT_ID // [!code --]
|
||||
} // [!code --]
|
||||
} // [!code --]
|
||||
manifest: () => ({ // [!code ++]
|
||||
oauth2: { // [!code ++]
|
||||
client_id: import.meta.env.WXT_APP_CLIENT_ID // [!code ++]
|
||||
} // [!code ++]
|
||||
}), // [!code ++]
|
||||
});
|
||||
```
|
||||
|
||||
WXT can't load your `.env` files until after the config file has been loaded. So by using the function syntax for `manifest`, it defers creating the object until after the `.env` files are loaded into the process.
|
||||
|
||||
@@ -239,6 +239,7 @@ export default defineContentScript({
|
||||
onMount: (container) => {
|
||||
// Render your app to the UI container
|
||||
const unmount = render(() => <div>...</div>, container);
|
||||
return unmount;
|
||||
},
|
||||
onRemove: (unmount) => {
|
||||
// Unmount the app when the UI is removed
|
||||
@@ -547,16 +548,21 @@ To use `injectScript`, we need two entrypoints, one content script and one unlis
|
||||
```ts
|
||||
// entrypoints/example-main-world.ts
|
||||
export default defineUnlistedScript(() => {
|
||||
console.log('Hello from the main world!');
|
||||
console.log('Hello from the main world');
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// entrypoints/example.content.ts
|
||||
export default defineContentScript(async () => {
|
||||
await injectScript('/example-main-world.js', {
|
||||
keepInDom: true,
|
||||
});
|
||||
export default defineContentScript({
|
||||
matches: ['*://*/*'],
|
||||
async main() {
|
||||
console.log('Injecting script...');
|
||||
await injectScript('/example-main-world.js', {
|
||||
keepInDom: true,
|
||||
});
|
||||
console.log('Done!');
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -602,7 +608,7 @@ export default defineContentScript({
|
||||
matches: ['*://*.youtube.com/*'],
|
||||
main(ctx) {
|
||||
ctx.addEventListener(window, 'wxt:locationchange', ({ newUrl }) => {
|
||||
if (watchPattern.matches(newUrl)) mainWatch(ctx);
|
||||
if (watchPattern.includes(newUrl)) mainWatch(ctx);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -304,10 +304,7 @@ Follow the [Devtools Example](https://github.com/wxt-dev/examples/tree/main/exam
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewportYou are responsible for loading/running these scripts where needed. If necessary, don't forget to add the script and/or any related stylesheets to web_accessible_resources."
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Options Title</title>
|
||||
<meta name="manifest.open_in_tab" content="true|false" />
|
||||
<meta name="manifest.chrome_style" content="true|false" />
|
||||
|
||||
@@ -35,7 +35,7 @@ Here's a brief summary of each of these files and directories:
|
||||
- `hooks/`: Auto-imported by default, contains hooks for React and Solid
|
||||
- `public/`: Contains any files you want to copy into the output folder as-is, without being processed by WXT
|
||||
- `utils/`: Auto-imported by default, contains generic utilities used throughout your project
|
||||
- `.env`: Contains [Environment Variables](/guide/essentials/config/runtime#environment-variables)
|
||||
- `.env`: Contains [Environment Variables](/guide/essentials/config/environment-variables)
|
||||
- `.env.publish`: Contains Environment Variables for [publishing](/guide/essentials/publishing)
|
||||
- `app.config.ts`: Contains [Runtime Config](/guide/essentials/config/runtime)
|
||||
- `package.json`: The standard file used by your package manager
|
||||
|
||||
@@ -129,7 +129,7 @@ wxt zip
|
||||
|
||||
Firefox requires you to upload a ZIP of your source code. This allows them to rebuild your extension and review the code in a readable way. More details can be found in [Firefox's docs](https://extensionworkshop.com/documentation/publish/source-code-submission/).
|
||||
|
||||
When running `wxt zip -b firefox`, WXT will zip both your extension and sources. Certain files (such as config files, hidden files, and tests) are automatically excluded from your sources. However, it's important to manually check the ZIP to ensure it only contains the files necessary to rebuild your extension.
|
||||
When running `wxt zip -b firefox`, WXT will zip both your extension and sources. Certain files (such as config files, hidden files, tests, and excluded entrypoints) are automatically excluded from your sources. However, it's important to manually check the ZIP to ensure it only contains the files necessary to rebuild your extension.
|
||||
|
||||
To customize which files are zipped, add the `zip` option to your config file.
|
||||
|
||||
|
||||
@@ -12,15 +12,18 @@ When using `browser.scripting.executeScript`, you can execute content scripts or
|
||||
// entrypoints/background.ts
|
||||
const res = await browser.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['injected.js'],
|
||||
files: ['content-scripts/example.js'],
|
||||
});
|
||||
console.log(res); // "Hello John!"
|
||||
```
|
||||
|
||||
```ts
|
||||
// entrypoints/injected.js
|
||||
export default defineUnlistedScript(() => {
|
||||
console.log('Script was injected!');
|
||||
return 'Hello John!';
|
||||
// entrypoints/example.content.ts
|
||||
export default defineContentScript({
|
||||
registration: 'runtime',
|
||||
main(ctx) {
|
||||
console.log('Script was executed!');
|
||||
return 'Hello John!';
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -75,3 +75,5 @@ Here are some examples:
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Alternatively, you can use the [`filterEntrypoints` config](/api/reference/wxt/interfaces/InlineConfig#filterentrypoints) to list all the entrypoints you want to build.
|
||||
|
||||
@@ -75,7 +75,7 @@ import { defineWxtModule } from 'wxt/modules';
|
||||
|
||||
export default defineWxtModule({
|
||||
setup(wxt) {
|
||||
wxt.hook('ready', () => {
|
||||
wxt.hook('config:resolved', () => {
|
||||
wxt.config.outDir = 'dist';
|
||||
});
|
||||
},
|
||||
|
||||
@@ -5,11 +5,11 @@ WXT is a modern, open-source framework for building web extensions. Inspired by
|
||||
- Provide an awesome [DX](https://about.gitlab.com/topics/devops/what-is-developer-experience/)
|
||||
- Provide first-class support for all major browsers
|
||||
|
||||
Check out the [comparison](/guide/resources/compare) to see how WXT compares to other tools for building web extnesions.
|
||||
Check out the [comparison](/guide/resources/compare) to see how WXT compares to other tools for building web extensions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
These docs assume you have a basic knowledge of how Chrome extensions are structured and how you access the extension APIs.
|
||||
These docs assume you have a basic knowledge of how web extensions are structured and how you access the extension APIs.
|
||||
|
||||
:::warning New to extension development?
|
||||
If you have never written an extension before, follow Chrome's [Hello World tutorial](https://developer.chrome.com/docs/extensions/get-started/tutorial/hello-world) to first **_create an extension without WXT_**, then come back here.
|
||||
|
||||
@@ -8,50 +8,59 @@ Lets compare the features of WXT vs [Plasmo](https://docs.plasmo.com/framework)
|
||||
- 🟡 - Partial support
|
||||
- ❌ - No support
|
||||
|
||||
| Features | WXT | Plasmo | CRXJS |
|
||||
| ------------------------------------------------------- | :--------------: | :-------------: | :--------------: |
|
||||
| Supports all browsers | ✅ | ✅ | 🟡 <sup>10</sup> |
|
||||
| MV2 Support | ✅ | ✅ | 🟡 <sup>1</sup> |
|
||||
| MV3 Support | ✅ | ✅ | 🟡 <sup>1</sup> |
|
||||
| Create Extension ZIPs | ✅ | ✅ | ❌ |
|
||||
| Create Firefox Sources ZIP | ✅ | ❌ | ❌ |
|
||||
| First-class TypeScript support | ✅ | ✅ | ✅ |
|
||||
| Entrypoint discovery | ✅ <sup>2</sup> | ✅ <sup>2</sup> | ❌ |
|
||||
| Inline entrypoint config | ✅ | ✅ | ❌ <sup>9</sup> |
|
||||
| Auto-imports | ✅ | ❌ | ❌ |
|
||||
| Reusable module system | ✅ | ❌ | ❌ |
|
||||
| Supports all frontend frameworks | ✅ | 🟡 <sup>3</sup> | ✅ |
|
||||
| Framework specific entrypoints (like `Popup.tsx`) | 🟡 <sup>4</sup> | ✅ <sup>5</sup> | ❌ |
|
||||
| Automated publishing | ✅ | ✅ | ❌ |
|
||||
| Remote Code Bundling (Google Analytics) | ✅ | ✅ | ❌ |
|
||||
| Unlisted HTML Pages | ✅ | ✅ | ✅ |
|
||||
| Unlisted Scripts | ✅ | ❌ | ❌ |
|
||||
| ESM Content Scripts | ❌ <sup>12</sup> | ❌ | ✅ |
|
||||
| <strong style="opacity: 50%">Dev Mode</strong> | | |
|
||||
| `.env` Files | ✅ | ✅ | ✅ |
|
||||
| Opens browser with extension installed | ✅ | ❌ | ❌ |
|
||||
| HMR for UIs | ✅ | 🟡 <sup>6</sup> | ✅ |
|
||||
| Reload HTML Files on Change | ✅ | 🟡 <sup>7</sup> | ✅ |
|
||||
| Reload Content Scripts on Change | ✅ | 🟡 <sup>7</sup> | ✅ |
|
||||
| Reload Background on Change | 🟡 <sup>7</sup> | 🟡 <sup>7</sup> | 🟡 <sup>7</sup> |
|
||||
| Respects Content Script `run_at` | ✅ | ✅ | ❌ <sup>8</sup> |
|
||||
| <strong style="opacity: 50%">Built-in Wrappers</strong> | | | |
|
||||
| Storage | ✅ | ✅ | ❌ <sup>11</sup> |
|
||||
| Messaging | ❌ <sup>11</sup> | ✅ | ❌ <sup>11</sup> |
|
||||
| Content Script UI | ✅ | ✅ | ❌ <sup>11</sup> |
|
||||
| I18n | ✅ | ❌ | ❌ |
|
||||
| Features | WXT | Plasmo | CRXJS |
|
||||
| ------------------------------------------------------- | :-----: | :-----: | :-----: |
|
||||
| Supports all browsers | ✅ | ✅ | 🟡 [^j] |
|
||||
| MV2 Support | ✅ | ✅ | 🟡 [^a] |
|
||||
| MV3 Support | ✅ | ✅ | 🟡 [^a] |
|
||||
| Create Extension ZIPs | ✅ | ✅ | ❌ |
|
||||
| Create Firefox Sources ZIP | ✅ | ❌ | ❌ |
|
||||
| First-class TypeScript support | ✅ | ✅ | ✅ |
|
||||
| Entrypoint discovery | ✅ [^b] | ✅ [^b] | ❌ |
|
||||
| Inline entrypoint config | ✅ | ✅ | ❌ [^i] |
|
||||
| Auto-imports | ✅ | ❌ | ❌ |
|
||||
| Reusable module system | ✅ | ❌ | ❌ |
|
||||
| Supports all frontend frameworks | ✅ | 🟡 [^c] | ✅ |
|
||||
| Framework specific entrypoints (like `Popup.tsx`) | 🟡 [^d] | ✅ [^e] | ❌ |
|
||||
| Automated publishing | ✅ | ✅ | ❌ |
|
||||
| Remote Code Bundling (Google Analytics) | ✅ | ✅ | ❌ |
|
||||
| Unlisted HTML Pages | ✅ | ✅ | ✅ |
|
||||
| Unlisted Scripts | ✅ | ❌ | ❌ |
|
||||
| ESM Content Scripts | ❌ [^l] | ❌ | ✅ |
|
||||
| <strong style="opacity: 50%">Dev Mode</strong> | | | |
|
||||
| `.env` Files | ✅ | ✅ | ✅ |
|
||||
| Opens browser with extension installed | ✅ | ❌ | ❌ |
|
||||
| HMR for UIs | ✅ | 🟡 [^f] | ✅ |
|
||||
| Reload HTML Files on Change | ✅ | 🟡 [^g] | ✅ |
|
||||
| Reload Content Scripts on Change | ✅ | 🟡 [^g] | ✅ |
|
||||
| Reload Background on Change | 🟡 [^g] | 🟡 [^g] | 🟡 [^g] |
|
||||
| Respects Content Script `run_at` | ✅ | ✅ | ❌ [^h] |
|
||||
| <strong style="opacity: 50%">Built-in Wrappers</strong> | | | |
|
||||
| Storage | ✅ | ✅ | ❌ [^k] |
|
||||
| Messaging | ❌ [^k] | ✅ | ❌ [^k] |
|
||||
| Content Script UI | ✅ | ✅ | ❌ [^k] |
|
||||
| I18n | ✅ | ❌ | ❌ |
|
||||
|
||||
<small>
|
||||
<sup>1</sup>: Either MV2 or MV3, not both.
|
||||
<br/><sup>2</sup>: File based.
|
||||
<br/><sup>3</sup>: Only React, Vue, and Svelte.
|
||||
<br/><sup>4</sup>: <code>.html</code> <code>.ts</code> <code>.tsx</code>.
|
||||
<br/><sup>5</sup>: <code>.html</code> <code>.ts</code> <code>.tsx</code>. <code>.vue</code> <code>.svelte</code>.
|
||||
<br/><sup>6</sup>: React only.
|
||||
<br/><sup>7</sup>: Reloads entire extension.
|
||||
<br/><sup>8</sup>: ESM-style loaders run asynchronously.
|
||||
<br/><sup>9</sup>: Entrypoint options all configured in <code>manifest.json</code>.
|
||||
<br/><sup>10</sup>: As of <code>v2.0.0-beta.23</code>, but v2 stable hasn't been released yet.
|
||||
<br/><sup>11</sup>: There is no built-in wrapper around this API. However, you can still access the standard APIs via <code>chrome</code>/<code>browser</code> globals or use any 3rd party NPM package.
|
||||
<br/><sup>12</sup>: WIP, moving very slowly. Follow <a href="https://github.com/wxt-dev/wxt/issues/357" target="_blank"><code>wxt-dev/wxt#357</code></a> for updates.
|
||||
</small>
|
||||
[^a]: Either MV2 or MV3, not both.
|
||||
|
||||
[^b]: File based.
|
||||
|
||||
[^c]: Only React, Vue, and Svelte.
|
||||
|
||||
[^d]: `.html`, `.ts`, `.tsx`.
|
||||
|
||||
[^e]: `.html`, `.ts`, `.tsx`, `.vue`, `.svelte`.
|
||||
|
||||
[^f]: React only.
|
||||
|
||||
[^g]: Reloads entire extension.
|
||||
|
||||
[^h]: ESM-style loaders run asynchronously.
|
||||
|
||||
[^i]: Entrypoint options all configured in `manifest.json`.
|
||||
|
||||
[^j]: As of `v2.0.0-beta.23`, but v2 stable hasn't been released yet.
|
||||
|
||||
[^k]: There is no built-in wrapper around this API. However, you can still access the standard APIs via `chrome`/`browser` globals or use any 3rd party NPM package.
|
||||
|
||||
[^l]: WIP, moving very slowly. Follow [wxt-dev/wxt#357](https://github.com/wxt-dev/wxt/issues/357) for updates.
|
||||
|
||||
@@ -8,9 +8,7 @@ Commonly asked questions about how to use WXT or why it behaves the way it does.
|
||||
|
||||
[[toc]]
|
||||
|
||||
---
|
||||
|
||||
### Why are content scripts not showing up in the manifest?
|
||||
## Why aren't content scripts added to the manifest?
|
||||
|
||||
During development, WXT registers content scripts dynamically so they can be reloaded individually when a file is saved without reloading your entire extension.
|
||||
|
||||
@@ -19,3 +17,58 @@ To list the content scripts registered during development, open the service work
|
||||
```js
|
||||
await chrome.scripting.getRegisteredContentScripts();
|
||||
```
|
||||
|
||||
## How do I disable opening the browser automatically during development?
|
||||
|
||||
See https://wxt.dev/guide/essentials/config/browser-startup.html#disable-opening-browser
|
||||
|
||||
## How do I stay logged into a website during development?
|
||||
|
||||
See https://wxt.dev/guide/essentials/config/browser-startup.html#persist-data
|
||||
|
||||
## My component library doesn't work in content scripts!
|
||||
|
||||
Component libraries place their CSS in the document's `<head>` by default. When using `createShadowRoot`, your UI is isolated from the document's styles because it's inside a ShadowRoot.
|
||||
|
||||
To fix this, you need to tell your component library to insert it's CSS inside the shadow root. Here's the docs for a couple of popular libraries:
|
||||
|
||||
- React
|
||||
- Ant Design: [`StyleProvider`](https://ant.design/docs/react/compatible-style#shadow-dom-usage)
|
||||
- Mantine: [`MantineProvider#getRootElement` and `MantineProvider#cssVariablesSelector`](https://mantine.dev/theming/mantine-provider/)
|
||||
|
||||
> If your library isn't listed above, try searching it's docs/issues for "shadow root", "shadow dom", or "css container".
|
||||
|
||||
`createShadowRoot` provides it's own `<head>` element inside the shadow root, so that were you should tell the library to add the CSS. Here's an example with Ant Design:
|
||||
|
||||
```tsx
|
||||
import { StyleProvider } from '@ant-design/cssinjs'; // [!code ++]
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'example-ui',
|
||||
position: 'inline',
|
||||
anchor: 'body',
|
||||
onMount: (container) => { // [!code --]
|
||||
onMount: (container, shadow) => { // [!code ++]
|
||||
const cssContainer = shadow.querySelector("head")!; // [!code ++]
|
||||
const root = ReactDOM.createRoot(container);
|
||||
root.render(
|
||||
<StyleProvider container={cssContainer}> // [!code ++]
|
||||
<App />
|
||||
</StyleProvider> // [!code ++]
|
||||
);
|
||||
return root;
|
||||
},
|
||||
onRemove: (root) => {
|
||||
root?.unmount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Note that this doesn't effect all component libraries, just ones that inject CSS themselves rather than having you import their CSS. This approach is more prevailent in the React community, but not limited to it. That's why only React libraries are listed above. Vuetify, for example, works just fine because you import its CSS - WXT picks up on this and the CSS is added inside the shadow root automatically:
|
||||
|
||||
```ts
|
||||
import 'vuetify/styles'; // <-- This line imports the CSS, just like importing a .css file
|
||||
import { createVuetify } from 'vuetify';
|
||||
```
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ title: Next-gen Web Extension Framework
|
||||
hero:
|
||||
name: WXT
|
||||
text: Next-gen Web Extension Framework
|
||||
tagline: An open source tool that makes Chrome Extension development faster than ever before.
|
||||
tagline: An open source tool that makes web extension development faster than ever before.
|
||||
image:
|
||||
src: /hero-logo.svg
|
||||
alt: WXT
|
||||
@@ -75,7 +75,7 @@ features:
|
||||
|
||||
## Put <span style="color: var(--vp-c-brand-1)">Developer Experience</span> First
|
||||
|
||||
WXT 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.
|
||||
WXT simplifies the web 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.
|
||||
|
||||
<div style="margin: auto; width: 100%; max-width: 900px; text-align: center">
|
||||
<video src="https://github.com/wxt-dev/wxt/assets/10101283/4d678939-1bdb-495c-9c36-3aa281d84c94" controls></video>
|
||||
@@ -87,7 +87,7 @@ WXT simplifies the chrome extension development process by providing tools for z
|
||||
|
||||
## Who's Using WXT?
|
||||
|
||||
Battle tested and ready for production. Explore chrome extensions made with WXT.
|
||||
Battle tested and ready for production. Explore web extensions made with WXT.
|
||||
|
||||
<ClientOnly>
|
||||
<UsingWxtSection />
|
||||
|
||||
+69
-22
@@ -6,23 +6,47 @@ outline: deep
|
||||
|
||||
[Changelog](https://github.com/wxt-dev/wxt/blob/main/packages/wxt/CHANGELOG.md)
|
||||
|
||||
WXT provides a simplified API to replace the `browser.storage.*` APIs. Use the `storage` auto-import from `wxt/storage` or import it manually to get started:
|
||||
A simplified wrapper around the extension storage APIs.
|
||||
|
||||
## Installation
|
||||
|
||||
### With WXT
|
||||
|
||||
This module is built-in to WXT, so you don't need to install anything.
|
||||
|
||||
```ts
|
||||
import { storage } from 'wxt/storage';
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> To use the `wxt/storage` API, the `"storage"` permission must be added to the manifest:
|
||||
>
|
||||
> ```ts
|
||||
> // wxt.config.ts
|
||||
> export default defineConfig({
|
||||
> manifest: {
|
||||
> permissions: ['storage'],
|
||||
> },
|
||||
> });
|
||||
> ```
|
||||
If you use auto-imports, `storage` is auto-imported for you, so you don't even need to import it!
|
||||
|
||||
### Without WXT
|
||||
|
||||
Install the NPM package:
|
||||
|
||||
```sh
|
||||
npm i @wxt-dev/storage
|
||||
pnpm add @wxt-dev/storage
|
||||
yarn add @wxt-dev/storage
|
||||
bun add @wxt-dev/storage
|
||||
```
|
||||
|
||||
```ts
|
||||
import { storage } from '@wxt-dev/storage';
|
||||
```
|
||||
|
||||
## Storage Permission
|
||||
|
||||
To use the `wxt/storage` API, the `"storage"` permission must be added to the manifest:
|
||||
|
||||
```ts
|
||||
// wxt.config.ts
|
||||
export default defineConfig({
|
||||
manifest: {
|
||||
permissions: ['storage'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
@@ -51,11 +75,11 @@ await storage.watch<number>(
|
||||
await storage.getMeta<{ v: number }>('local:installDate');
|
||||
```
|
||||
|
||||
For a full list of methods available, see the [API reference](/api/reference/wxt/storage/interfaces/WxtStorage).
|
||||
For a full list of methods available, see the [API reference](/api/reference/@wxt-dev/storage/interfaces/WxtStorage).
|
||||
|
||||
## Watchers
|
||||
|
||||
To listen for storage changes, use the `storage.watch` function. It lets you setup a listener for a single key:
|
||||
To listen for storage changes, use the `storage.watch` function. It lets you set up a listener for a single key:
|
||||
|
||||
```ts
|
||||
const unwatch = storage.watch<number>('local:counter', (newCount, oldCount) => {
|
||||
@@ -100,7 +124,7 @@ You can remove all metadata associated with a key, or just specific properties:
|
||||
// Remove all properties
|
||||
await storage.removeMeta('local:preference');
|
||||
|
||||
// Remove one property
|
||||
// Remove only the "lastModified" property
|
||||
await storage.removeMeta('local:preference', 'lastModified');
|
||||
|
||||
// Remove multiple properties
|
||||
@@ -134,7 +158,7 @@ const unwatch = showChangelogOnUpdate.watch((newValue) => {
|
||||
});
|
||||
```
|
||||
|
||||
For a full list of properties and methods available, see the [API reference](/api/reference/wxt/storage/interfaces/WxtStorageItem).
|
||||
For a full list of properties and methods available, see the [API reference](/api/reference/@wxt-dev/storage/interfaces/WxtStorageItem).
|
||||
|
||||
### Versioning
|
||||
|
||||
@@ -225,13 +249,13 @@ export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV3[]>( // [!code
|
||||
Internally, this uses a metadata property called `v` to track the value's current version.
|
||||
:::
|
||||
|
||||
In this case, we thought that the ignored website list might change in the future, and were able to setup a versioned storage item from the start.
|
||||
In this case, we thought that the ignored website list might change in the future, and were able to set up a versioned storage item from the start.
|
||||
|
||||
Realistically, you won't know a item needs versioned until you need to change it's schema. Thankfully, it's simple to add versioning to an unversioned storage item.
|
||||
Realistically, you won't know an item needs versioning until you need to change its schema. Thankfully, it's simple to add versioning to an unversioned storage item.
|
||||
|
||||
When a previous version isn't found, WXT assumes the version was `1`. That means you just need to set `version: 2` and add a migration for `2`, and it will just work!
|
||||
|
||||
Lets look at the same ignored websites example from before, but start with an unversioned item this time:
|
||||
Let's look at the same ignored websites example from before, but start with an unversioned item this time:
|
||||
|
||||
:::code-group
|
||||
|
||||
@@ -275,13 +299,13 @@ export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV2[]>( // [!code
|
||||
|
||||
### Running Migrations
|
||||
|
||||
As soon as `storage.defineItem` is called, WXT checks if migrations need to be ran, and if so, runs them. Calls to get or update the storage item's value or metadata (`getValue`, `setValue`, `removeValue`, `getMeta`, etc) will automatically wait for the migration process to finish before actually reading or writing values.
|
||||
As soon as `storage.defineItem` is called, WXT checks if migrations need to be run, and if so, runs them. Calls to get or update the storage item's value or metadata (`getValue`, `setValue`, `removeValue`, `getMeta`, etc.) will automatically wait for the migration process to finish before actually reading or writing values.
|
||||
|
||||
### Default Values
|
||||
|
||||
With `storage.defineItem`, there are multiple ways of defining default values:
|
||||
|
||||
1. `fallback` - Return this value from `getValue` instead of `null` if the value is missing.
|
||||
1. **`fallback`** - Return this value from `getValue` instead of `null` if the value is missing.
|
||||
|
||||
This option is great for providing default values for settings:
|
||||
|
||||
@@ -294,7 +318,7 @@ With `storage.defineItem`, there are multiple ways of defining default values:
|
||||
});
|
||||
```
|
||||
|
||||
2. `init` - Initialize and save a value in storage if it is not already saved.
|
||||
2. **`init`** - Initialize and save a value in storage if it is not already saved.
|
||||
|
||||
This is great for values that need to be initialized or set once:
|
||||
|
||||
@@ -308,3 +332,26 @@ With `storage.defineItem`, there are multiple ways of defining default values:
|
||||
```
|
||||
|
||||
The value is initialized in storage immediately.
|
||||
|
||||
## Bulk Operations
|
||||
|
||||
When getting or setting multiple values in storage, you can perform bulk operations to improve performance by reducing the number of individual storage calls. The `storage` API provides several methods for performing bulk operations:
|
||||
|
||||
- **`getItems`** - Get multiple values at once.
|
||||
- **`getMetas`** - Get metadata for multiple items at once.
|
||||
- **`setItems`** - Set multiple values at once.
|
||||
- **`setMetas`** - Set metadata for multiple items at once.
|
||||
- **`removeItems`** - Remove multiple values (and optionally metadata) at once.
|
||||
|
||||
All these APIs support both string keys and defined storage items:
|
||||
|
||||
```ts
|
||||
const userId = storage.defineItem('local:userId');
|
||||
|
||||
await storage.setItems([
|
||||
{ key: 'local:installDate', value: Date.now() },
|
||||
{ item: userId, value: generateUserId() },
|
||||
]);
|
||||
```
|
||||
|
||||
Refer to the [API Reference](/api/reference/@wxt-dev/storage/interfaces/WxtStorage) for types and examples of how to use all the bulk APIs.
|
||||
|
||||
+9
-2
@@ -2,11 +2,18 @@
|
||||
"$schema": "https://typedoc.org/schema.json",
|
||||
"entryPointStrategy": "packages",
|
||||
"entryPoints": ["../packages/wxt"],
|
||||
"plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme"],
|
||||
"plugin": [
|
||||
"typedoc-plugin-markdown",
|
||||
"typedoc-vitepress-theme",
|
||||
"typedoc-plugin-frontmatter"
|
||||
],
|
||||
"out": "./api/reference",
|
||||
"githubPages": false,
|
||||
"excludePrivate": true,
|
||||
"excludeProtected": true,
|
||||
"excludeInternal": true,
|
||||
"readme": "none"
|
||||
"readme": "none",
|
||||
"frontmatterGlobals": {
|
||||
"editLink": false
|
||||
}
|
||||
}
|
||||
|
||||
+11
-6
@@ -4,7 +4,7 @@
|
||||
"engines": {
|
||||
"node": ">=18.20.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"packageManager": "pnpm@9.13.0",
|
||||
"scripts": {
|
||||
"check": "check && pnpm -r --sequential run check",
|
||||
"test": "pnpm -r --sequential run test run",
|
||||
@@ -20,23 +20,25 @@
|
||||
"@aklinker1/buildc": "^1.1.4",
|
||||
"@aklinker1/check": "^1.4.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@vitest/coverage-v8": "^2.1.2",
|
||||
"@vitest/coverage-v8": "^2.1.4",
|
||||
"changelogen": "^0.5.7",
|
||||
"consola": "^3.2.3",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
"lint-staged": "^15.2.10",
|
||||
"nano-spawn": "^0.1.0",
|
||||
"markdown-it-footnote": "^4.0.0",
|
||||
"nano-spawn": "^0.2.0",
|
||||
"prettier": "^3.3.3",
|
||||
"simple-git-hooks": "^2.11.1",
|
||||
"tsx": "4.15.7",
|
||||
"typedoc": "^0.25.4",
|
||||
"typedoc-plugin-frontmatter": "^1.1.0",
|
||||
"typedoc-plugin-markdown": "4.0.0-next.23",
|
||||
"typedoc-vitepress-theme": "1.0.0-next.3",
|
||||
"typescript": "^5.6.2",
|
||||
"vitepress": "^1.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vitepress": "^1.5.0",
|
||||
"vitest-mock-extended": "^2.0.2",
|
||||
"vue": "^3.5.11",
|
||||
"vue": "^3.5.12",
|
||||
"wxt": "workspace:*"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
@@ -51,6 +53,9 @@
|
||||
"@algolia/client-search",
|
||||
"search-insights"
|
||||
]
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"markdown-it-footnote": "patches/markdown-it-footnote.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,4 +41,4 @@ export default defineConfig({
|
||||
});
|
||||
```
|
||||
|
||||
Options have JSDocs available in your editor, or you can read them in the source code: [`AutoIconsOptions`](./src/index.ts).
|
||||
Options have JSDocs available in your editor, or you can read them in the source code: [`AutoIconsOptions`](https://github.com/wxt-dev/wxt/blob/main/packages/auto-icons/src/index.ts).
|
||||
|
||||
@@ -46,9 +46,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aklinker1/check": "^1.4.5",
|
||||
"oxlint": "^0.9.9",
|
||||
"publint": "^0.2.11",
|
||||
"typescript": "^5.6.2",
|
||||
"oxlint": "^0.11.1",
|
||||
"publint": "^0.2.12",
|
||||
"typescript": "^5.6.3",
|
||||
"unbuild": "^2.0.0",
|
||||
"wxt": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## v0.2.3
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/i18n-v0.2.2...i18n-v0.2.3)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Prevent app crashes from parse errors due to incomplete file saves ([#1114](https://github.com/wxt-dev/wxt/pull/1114))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Cleanup typos and broken links ([bb5ea34](https://github.com/wxt-dev/wxt/commit/bb5ea34))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Bread Grocery <breadgrocery@gmail.com>
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
|
||||
## v0.2.2
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/i18n-v0.2.1...i18n-v0.2.2)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Add support for configuring the `locales` directory ([#1109](https://github.com/wxt-dev/wxt/pull/1109))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Bread Grocery <breadgrocery@gmail.com>
|
||||
|
||||
## v0.2.1
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/i18n-v0.2.0...i18n-v0.2.1)
|
||||
|
||||
+18
-3
@@ -98,6 +98,21 @@ And you're done! Using WXT, you get type-safety out of the box.
|
||||
i18n.t('helloWorld'); // "Hello world!";
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The module can be configured via the `i18n` config:
|
||||
|
||||
```ts
|
||||
export default defineConfig({
|
||||
modules: ['@wxt-dev/i18n'],
|
||||
i18n: {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Options have JSDocs available in your editor, or you can read them in the source code: [`I18nOptions`](https://github.com/wxt-dev/wxt/blob/main/packages/i18n/src/module.ts).
|
||||
|
||||
## Messages File Format
|
||||
|
||||
> [!DANGER]
|
||||
@@ -225,11 +240,11 @@ A key is treated as "verbose" when it is:
|
||||
{
|
||||
"appName": {
|
||||
"message": "GitHub - Better Line Counts",
|
||||
"description": "The app's name, should not be translated",
|
||||
"description": "The app's name, should not be translated"
|
||||
},
|
||||
"ok": "OK",
|
||||
"deleteConfirmation": {
|
||||
"title": "Delete XYZ?"
|
||||
"title": "Delete XYZ?",
|
||||
"message": "You cannot undo this action once taken."
|
||||
}
|
||||
}
|
||||
@@ -289,7 +304,7 @@ const messages = {
|
||||
// ...
|
||||
};
|
||||
|
||||
// Generate JSON files for the browser
|
||||
// Generate JSON files for the extension
|
||||
await generateChromeMessagesFile('dist/_locales/en/messages.json', messages.en);
|
||||
await generateChromeMessagesFile('dist/_locales/de/messages.json', messages.de);
|
||||
// ...
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@wxt-dev/i18n",
|
||||
"description": "Type-safe wrapper around browser.i18n.getMessage with additional features",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
"confbox": "^0.1.7",
|
||||
"confbox": "^0.1.8",
|
||||
"fast-glob": "^3.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -39,13 +39,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aklinker1/check": "^1.4.5",
|
||||
"@types/chrome": "^0.0.268",
|
||||
"@types/node": "^20.16.10",
|
||||
"oxlint": "^0.9.9",
|
||||
"publint": "^0.2.11",
|
||||
"typescript": "^5.6.2",
|
||||
"@types/chrome": "^0.0.280",
|
||||
"@types/node": "^20.17.6",
|
||||
"oxlint": "^0.11.1",
|
||||
"publint": "^0.2.12",
|
||||
"typescript": "^5.6.3",
|
||||
"unbuild": "^2.0.0",
|
||||
"vitest": "^2.0.0",
|
||||
"vitest": "^2.1.4",
|
||||
"vitest-plugin-random-seed": "^1.1.0",
|
||||
"wxt": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -23,11 +23,11 @@ import { watch } from 'chokidar';
|
||||
import { GeneratedPublicFile, WxtDirFileEntry } from 'wxt';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
export default defineWxtModule({
|
||||
export default defineWxtModule<I18nOptions>({
|
||||
name: '@wxt-dev/i18n',
|
||||
configKey: 'i18n',
|
||||
imports: [{ from: '#i18n', name: 'i18n' }],
|
||||
|
||||
setup(wxt) {
|
||||
setup(wxt, options) {
|
||||
if (wxt.config.manifest.default_locale == null) {
|
||||
wxt.logger.warn(
|
||||
`\`[i18n]\` manifest.default_locale not set, \`@wxt-dev/i18n\` disabled.`,
|
||||
@@ -38,9 +38,12 @@ export default defineWxtModule({
|
||||
'`[i18n]` Default locale: ' + wxt.config.manifest.default_locale,
|
||||
);
|
||||
|
||||
const { localesDir = resolve(wxt.config.srcDir, 'locales') } =
|
||||
options ?? {};
|
||||
|
||||
const getLocalizationFiles = async () => {
|
||||
const files = await glob('locales/*', {
|
||||
cwd: wxt.config.srcDir,
|
||||
const files = await glob('*.{json,json5,yml,yaml,toml}', {
|
||||
cwd: localesDir,
|
||||
absolute: true,
|
||||
});
|
||||
return files.map((file) => ({
|
||||
@@ -71,7 +74,7 @@ export default defineWxtModule({
|
||||
)!;
|
||||
if (defaultLocaleFile == null) {
|
||||
throw Error(
|
||||
`\`[i18n]\` Required localization file does not exist: \`<srcDir>/locales/${wxt.config.manifest.default_locale}.{json|json5|yml|yaml|toml}\``,
|
||||
`\`[i18n]\` Required localization file does not exist: \`<localesDir>/${wxt.config.manifest.default_locale}.{json|json5|yml|yaml|toml}\``,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,9 +157,28 @@ export { type GeneratedI18nStructure }
|
||||
|
||||
if (wxt.config.command === 'serve') {
|
||||
wxt.hooks.hookOnce('build:done', () => {
|
||||
const watcher = watch(resolve(wxt.config.srcDir, 'locales'));
|
||||
watcher.on('change', updateLocalizations);
|
||||
const watcher = watch(localesDir);
|
||||
watcher.on('change', (path) => {
|
||||
updateLocalizations(path).catch(wxt.logger.error);
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Options for the i18n module
|
||||
*/
|
||||
export interface I18nOptions {
|
||||
/**
|
||||
* Directory containing files that define the translations.
|
||||
* @default "${config.srcDir}/locales"
|
||||
*/
|
||||
localesDir?: string;
|
||||
}
|
||||
|
||||
declare module 'wxt' {
|
||||
export interface InlineConfig {
|
||||
i18n?: I18nOptions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## v1.1.2
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/module-react-v1.1.1...module-react-v1.1.2)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Use `config:resolved` hook to update config instead of `ready` ([#1178](https://github.com/wxt-dev/wxt/pull/1178))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Add `oxlint` for linting ([#947](https://github.com/wxt-dev/wxt/pull/947))
|
||||
- Upgrade all non-major dependencies ([#1040](https://github.com/wxt-dev/wxt/pull/1040))
|
||||
- **deps:** Upgrade all non-major dependencies ([#1164](https://github.com/wxt-dev/wxt/pull/1164))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
|
||||
## v1.1.1
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/module-react-v1.1.0...module-react-v1.1.1)
|
||||
|
||||
@@ -15,7 +15,7 @@ export default defineWxtModule<ReactModuleOptions>({
|
||||
addImportPreset(wxt, 'react');
|
||||
|
||||
// Enable auto-imports for JSX files
|
||||
wxt.hooks.hook('ready', (wxt) => {
|
||||
wxt.hook('config:resolved', (wxt) => {
|
||||
if (wxt.config.imports === false) return;
|
||||
|
||||
wxt.config.imports.dirsScanOptions ??= {};
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"email": "aaronklinker1+wxt@gmail.com"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -44,19 +44,19 @@
|
||||
"prepare": "buildc --deps-only -- wxt prepare"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"wxt": ">=0.18.6"
|
||||
"wxt": ">=0.19.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.2"
|
||||
"@vitejs/plugin-react": "^4.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aklinker1/check": "^1.4.5",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"publint": "^0.2.11",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"publint": "^0.2.12",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript": "^5.6.3",
|
||||
"unbuild": "^2.0.0",
|
||||
"wxt": "workspace:*"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## v1.1.3
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/module-solid-v1.1.2...module-solid-v1.1.3)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Use `config:resolved` hook to update config instead of `ready` ([#1178](https://github.com/wxt-dev/wxt/pull/1178))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Add `oxlint` for linting ([#947](https://github.com/wxt-dev/wxt/pull/947))
|
||||
- Upgrade all non-major dependencies ([#1040](https://github.com/wxt-dev/wxt/pull/1040))
|
||||
- **deps:** Upgrade all non-major dependencies ([#1164](https://github.com/wxt-dev/wxt/pull/1164))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
|
||||
## v1.1.2
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/module-solid-v1.1.1...module-solid-v1.1.2)
|
||||
|
||||
@@ -18,7 +18,7 @@ export default defineWxtModule<SolidModuleOptions>({
|
||||
addImportPreset(wxt, 'solid-js');
|
||||
|
||||
// Enable auto-imports for JSX files
|
||||
wxt.hooks.hook('ready', (wxt) => {
|
||||
wxt.hook('config:resolved', (wxt) => {
|
||||
if (wxt.config.imports === false) return;
|
||||
|
||||
wxt.config.imports.dirsScanOptions ??= {};
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"email": "aaronklinker1+wxt@gmail.com"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -44,16 +44,16 @@
|
||||
"prepare": "buildc --deps-only -- wxt prepare"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"wxt": ">=0.18.6"
|
||||
"wxt": ">=0.19.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"vite-plugin-solid": "^2.10.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aklinker1/check": "^1.4.5",
|
||||
"publint": "^0.2.11",
|
||||
"solid-js": "^1.9.1",
|
||||
"typescript": "^5.6.2",
|
||||
"publint": "^0.2.12",
|
||||
"solid-js": "^1.9.3",
|
||||
"typescript": "^5.6.3",
|
||||
"unbuild": "^2.0.0",
|
||||
"wxt": "workspace:*"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## v2.0.1
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/module-svelte-v2.0.0...module-svelte-v2.0.1)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Modify dev `vite.resolve.conditions` to support Vite 6 + Svelte 5 ([#1230](https://github.com/wxt-dev/wxt/pull/1230))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- **deps:** Upgrade all non-major dependencies ([#1164](https://github.com/wxt-dev/wxt/pull/1164))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
|
||||
## v2.0.0
|
||||
|
||||
[⚠️ breaking changes](https://wxt.dev/guide/upgrade-guide/wxt) • [compare changes](https://github.com/wxt-dev/wxt/compare/module-svelte-v1.0.1...module-svelte-v2.0.0)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- ⚠️ Svelte 5 support ([#1104](https://github.com/wxt-dev/wxt/pull/1104))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Add `oxlint` for linting ([#947](https://github.com/wxt-dev/wxt/pull/947))
|
||||
- Upgrade all non-major dependencies ([#1040](https://github.com/wxt-dev/wxt/pull/1040))
|
||||
|
||||
#### ⚠️ Breaking Changes
|
||||
|
||||
Upgraded `@sveltejs/vite-plugin-svelte` from v3 to v4. This drops support for Svelte 4 and below. To continue using older versions of Svelte, use v1 of this module.
|
||||
|
||||
To upgrade to svelte 5, just install `svelte@5`.
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
|
||||
## v1.0.1
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/module-svelte-v1.0.0...module-svelte-v1.0.1)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"email": "aaronklinker1+wxt@gmail.com"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "1.0.1",
|
||||
"version": "2.0.1",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -42,15 +42,16 @@
|
||||
"check": "pnpm build && check"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"wxt": ">=0.18.6"
|
||||
"wxt": ">=0.18.6",
|
||||
"svelte": ">=5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2"
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aklinker1/check": "^1.4.5",
|
||||
"publint": "^0.2.11",
|
||||
"typescript": "^5.6.2",
|
||||
"publint": "^0.2.12",
|
||||
"typescript": "^5.6.3",
|
||||
"unbuild": "^2.0.0",
|
||||
"wxt": "workspace:*"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineWxtModule<SvelteModuleOptions>({
|
||||
setup(wxt, options) {
|
||||
const { vite } = options ?? {};
|
||||
|
||||
addViteConfig(wxt, () => ({
|
||||
addViteConfig(wxt, ({ mode }) => ({
|
||||
plugins: [
|
||||
svelte({
|
||||
// Using a svelte.config.js file causes a segmentation fault when importing the file
|
||||
@@ -21,6 +21,9 @@ export default defineWxtModule<SvelteModuleOptions>({
|
||||
...vite,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
conditions: mode === 'development' ? ['browser'] : [],
|
||||
},
|
||||
}));
|
||||
|
||||
addImportPreset(wxt, 'svelte');
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## v1.0.2
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/module-vue-v1.0.1...module-vue-v1.0.2)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Use `config:resolved` hook to update config instead of `ready` ([#1178](https://github.com/wxt-dev/wxt/pull/1178))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Add `oxlint` for linting ([#947](https://github.com/wxt-dev/wxt/pull/947))
|
||||
- **deps:** Bump @vitejs/plugin-vue from 5.1.1 to 5.1.4 ([#1020](https://github.com/wxt-dev/wxt/pull/1020))
|
||||
- Upgrade all non-major dependencies ([#1040](https://github.com/wxt-dev/wxt/pull/1040))
|
||||
- **deps:** Upgrade all non-major dependencies ([#1164](https://github.com/wxt-dev/wxt/pull/1164))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
|
||||
## v1.0.1
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/module-vue-v1.0.0...module-vue-v1.0.1)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"email": "aaronklinker1+wxt@gmail.com"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -42,15 +42,15 @@
|
||||
"check": "pnpm build && check"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"wxt": ">=0.18.6"
|
||||
"wxt": ">=0.19.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4"
|
||||
"@vitejs/plugin-vue": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aklinker1/check": "^1.4.5",
|
||||
"publint": "^0.2.11",
|
||||
"typescript": "^5.6.2",
|
||||
"publint": "^0.2.12",
|
||||
"typescript": "^5.6.3",
|
||||
"unbuild": "^2.0.0",
|
||||
"wxt": "workspace:*"
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default defineWxtModule<VueModuleOptions>({
|
||||
}));
|
||||
|
||||
// Enable auto-imports in template files
|
||||
wxt.hooks.hook('ready', (wxt) => {
|
||||
wxt.hook('config:resolved', (wxt) => {
|
||||
if (!wxt.config.imports) return;
|
||||
|
||||
wxt.config.imports.addons ??= {};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## v1.0.1
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/storage-v1.0.0...storage-v1.0.1)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Use `browser` for mv2 storage ([#1200](https://github.com/wxt-dev/wxt/pull/1200))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Cleanup changelog ([f5b7f7e](https://github.com/wxt-dev/wxt/commit/f5b7f7e))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Init changelog for storage package ([6fc227b](https://github.com/wxt-dev/wxt/commit/6fc227b))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
|
||||
## v1.0.0
|
||||
|
||||
Extracted `wxt/storage` into it's own package, `@wxt-dev/storage`!
|
||||
|
||||
It's still shipped inside WXT and accessible via `wxt/storage`, but now:
|
||||
|
||||
- Non-WXT projects can use the storage wrapper.
|
||||
- We can make breaking changes to the API separately.
|
||||
|
||||
[Read the docs](https://wxt.dev/storage.html) for more details.
|
||||
|
||||
> This is apart of the v1.0 initiative for WXT.
|
||||
@@ -0,0 +1,36 @@
|
||||
# WXT Storage
|
||||
|
||||
[Changelog](https://github.com/wxt-dev/wxt/blob/main/packages/storage/CHANGELOG.md) • [Docs](https://wxt.dev/storage.html)
|
||||
|
||||
A simplified wrapper around the extension storage APIs.
|
||||
|
||||
## Installation
|
||||
|
||||
### With WXT
|
||||
|
||||
This module is built-in to WXT, so you don't need to install anything.
|
||||
|
||||
```ts
|
||||
import { storage } from 'wxt/storage';
|
||||
```
|
||||
|
||||
If you use auto-imports, `storage` is auto-imported for you, so you don't even need to import it!
|
||||
|
||||
### Without WXT
|
||||
|
||||
Install the NPM package:
|
||||
|
||||
```sh
|
||||
npm i @wxt-dev/storage
|
||||
pnpm add @wxt-dev/storage
|
||||
yarn add @wxt-dev/storage
|
||||
bun add @wxt-dev/storage
|
||||
```
|
||||
|
||||
```ts
|
||||
import { storage } from '@wxt-dev/storage';
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Read full docs on the [documentation website](https://wxt.dev/storage.html).
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@wxt-dev/storage",
|
||||
"description": "Web extension storage API provided by WXT, supports all browsers.",
|
||||
"version": "1.0.1",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/wxt-dev/wxt.git",
|
||||
"directory": "packages/storage"
|
||||
},
|
||||
"homepage": "https://wxt.dev/storage.html",
|
||||
"keywords": [
|
||||
"wxt",
|
||||
"storage",
|
||||
"extension",
|
||||
"addon",
|
||||
"chrome",
|
||||
"firefox",
|
||||
"edge"
|
||||
],
|
||||
"author": {
|
||||
"name": "Aaron Klinker",
|
||||
"email": "aaronklinker1+wxt@gmail.com"
|
||||
},
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "buildc -- unbuild",
|
||||
"check": "buildc --deps-only -- check",
|
||||
"test": "buildc --deps-only -- vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"async-mutex": "^0.5.0",
|
||||
"dequal": "^2.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aklinker1/check": "^1.4.5",
|
||||
"@types/chrome": "^0.0.268",
|
||||
"@webext-core/fake-browser": "^1.3.1",
|
||||
"oxlint": "^0.9.9",
|
||||
"publint": "^0.2.11",
|
||||
"typescript": "^5.6.2",
|
||||
"unbuild": "^2.0.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
+501
-40
@@ -1,7 +1,6 @@
|
||||
import { fakeBrowser } from '@webext-core/fake-browser';
|
||||
import { describe, it, expect, beforeEach, vi, expectTypeOf } from 'vitest';
|
||||
import { browser } from 'wxt/browser';
|
||||
import { WxtStorageItem, storage } from '../storage';
|
||||
import { MigrationError, type WxtStorageItem, storage } from '../index';
|
||||
|
||||
/**
|
||||
* This works because fakeBrowser is synchronous, and is will finish any number of chained
|
||||
@@ -38,7 +37,7 @@ describe('Storage Utils', () => {
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('should return the value if multiple : are use in the key', async () => {
|
||||
it('should return the value if multiple : are used in the key', async () => {
|
||||
const expected = 'value';
|
||||
await fakeBrowser.storage[storageArea].set({ 'some:key': expected });
|
||||
|
||||
@@ -64,27 +63,97 @@ describe('Storage Utils', () => {
|
||||
});
|
||||
|
||||
describe('getItems', () => {
|
||||
it('should return an array of values', async () => {
|
||||
const expected = [
|
||||
{ key: `${storageArea}:count`, value: 234 },
|
||||
{ key: `${storageArea}:installDate`, value: null },
|
||||
{ key: `${storageArea}:otherValue`, value: 345 },
|
||||
] as const;
|
||||
const params = [
|
||||
expected[0].key,
|
||||
expected[1].key,
|
||||
{
|
||||
key: expected[2].key,
|
||||
options: { defaultValue: expected[2].value },
|
||||
},
|
||||
];
|
||||
it('should get values from multiple storage keys', async () => {
|
||||
const item1 = {
|
||||
key: `${storageArea}:one`,
|
||||
expectedValue: 1,
|
||||
} as const;
|
||||
const item2 = {
|
||||
key: `${storageArea}:two`,
|
||||
expectedValue: null,
|
||||
} as const;
|
||||
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
count: expected[0].value,
|
||||
one: item1.expectedValue,
|
||||
});
|
||||
|
||||
const actual = await storage.getItems(params);
|
||||
const actual = await storage.getItems([item1.key, item2.key]);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
expect(actual).toEqual([
|
||||
{ key: item1.key, value: item1.expectedValue },
|
||||
{ key: item2.key, value: item2.expectedValue },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should get values from multiple storage items', async () => {
|
||||
const item1 = storage.defineItem(`${storageArea}:one`);
|
||||
const expectedValue1 = 1;
|
||||
const item2 = storage.defineItem(`${storageArea}:two`);
|
||||
const expectedValue2 = null;
|
||||
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
one: expectedValue1,
|
||||
});
|
||||
|
||||
const actual = await storage.getItems([item1, item2]);
|
||||
|
||||
expect(actual).toEqual([
|
||||
{ key: item1.key, value: expectedValue1 },
|
||||
{ key: item2.key, value: expectedValue2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should get values for a combination of different input types', async () => {
|
||||
const key1 = `${storageArea}:one` as const;
|
||||
const expectedValue1 = 1;
|
||||
const item2 = storage.defineItem<number>(`${storageArea}:two`);
|
||||
const expectedValue2 = 2;
|
||||
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
one: expectedValue1,
|
||||
two: expectedValue2,
|
||||
});
|
||||
|
||||
const actual = await storage.getItems([key1, item2]);
|
||||
|
||||
expect(actual).toEqual([
|
||||
{ key: key1, value: expectedValue1 },
|
||||
{ key: item2.key, value: expectedValue2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return fallback values for keys when provided', async () => {
|
||||
const key1 = `${storageArea}:one` as const;
|
||||
const expectedValue1 = null;
|
||||
const key2 = `${storageArea}:two` as const;
|
||||
const fallback2 = 2;
|
||||
const expectedValue2 = fallback2;
|
||||
|
||||
const actual = await storage.getItems([
|
||||
key1,
|
||||
{ key: key2, options: { fallback: fallback2 } },
|
||||
]);
|
||||
|
||||
expect(actual).toEqual([
|
||||
{ key: key1, value: expectedValue1 },
|
||||
{ key: key2, value: expectedValue2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return fallback values for items when provided', async () => {
|
||||
const item1 = storage.defineItem<number>(`${storageArea}:one`);
|
||||
const expectedValue1 = null;
|
||||
const item2 = storage.defineItem(`${storageArea}:two`, {
|
||||
fallback: 2,
|
||||
});
|
||||
const expectedValue2 = item2.fallback;
|
||||
|
||||
const actual = await storage.getItems([item1, item2]);
|
||||
|
||||
expect(actual).toEqual([
|
||||
{ key: item1.key, value: expectedValue1 },
|
||||
{ key: item2.key, value: expectedValue2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,7 +222,7 @@ describe('Storage Utils', () => {
|
||||
describe('setMeta', () => {
|
||||
it('should set metadata at key+$', async () => {
|
||||
const existing = { v: 1 };
|
||||
await browser.storage[storageArea].set({ count$: existing });
|
||||
await chrome.storage[storageArea].set({ count$: existing });
|
||||
const newValues = {
|
||||
date: Date.now(),
|
||||
};
|
||||
@@ -169,7 +238,7 @@ describe('Storage Utils', () => {
|
||||
'should remove any properties set to %s',
|
||||
async (version) => {
|
||||
const existing = { v: 1 };
|
||||
await browser.storage[storageArea].set({ count$: existing });
|
||||
await chrome.storage[storageArea].set({ count$: existing });
|
||||
const expected = {};
|
||||
|
||||
await storage.setMeta(`${storageArea}:count`, { v: version });
|
||||
@@ -180,6 +249,74 @@ describe('Storage Utils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('setMetas', () => {
|
||||
it('should set key metadata correctly', async () => {
|
||||
const key1 = `${storageArea}:one` as const;
|
||||
const initialMeta1 = {};
|
||||
const setMeta1 = { v: 1 };
|
||||
const expectedMeta1 = setMeta1;
|
||||
|
||||
const key2 = `${storageArea}:two` as const;
|
||||
const initialMeta2 = { v: 1 };
|
||||
const setMeta2 = { v: 2 };
|
||||
const expectedMeta2 = setMeta2;
|
||||
|
||||
const key3 = `${storageArea}:three` as const;
|
||||
const initialMeta3 = { v: 1 };
|
||||
const setMeta3 = { d: Date.now() };
|
||||
const expectedMeta3 = { ...initialMeta3, ...setMeta3 };
|
||||
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
one$: initialMeta1,
|
||||
two$: initialMeta2,
|
||||
three$: initialMeta3,
|
||||
});
|
||||
|
||||
await storage.setMetas([
|
||||
{ key: key1, meta: setMeta1 },
|
||||
{ key: key2, meta: setMeta2 },
|
||||
{ key: key3, meta: setMeta3 },
|
||||
]);
|
||||
|
||||
expect(await storage.getMeta(key1)).toEqual(expectedMeta1);
|
||||
expect(await storage.getMeta(key2)).toEqual(expectedMeta2);
|
||||
expect(await storage.getMeta(key3)).toEqual(expectedMeta3);
|
||||
});
|
||||
|
||||
it('should set item metadata correctly', async () => {
|
||||
const item1 = storage.defineItem(`${storageArea}:one`);
|
||||
const initialMeta1 = {};
|
||||
const setMeta1 = { v: 1 };
|
||||
const expectedMeta1 = setMeta1;
|
||||
|
||||
const item2 = storage.defineItem(`${storageArea}:two`);
|
||||
const initialMeta2 = { v: 1 };
|
||||
const setMeta2 = { v: 2 };
|
||||
const expectedMeta2 = setMeta2;
|
||||
|
||||
const item3 = storage.defineItem(`${storageArea}:three`);
|
||||
const initialMeta3 = { v: 1 };
|
||||
const setMeta3 = { d: Date.now() };
|
||||
const expectedMeta3 = { ...initialMeta3, ...setMeta3 };
|
||||
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
one$: initialMeta1,
|
||||
two$: initialMeta2,
|
||||
three$: initialMeta3,
|
||||
});
|
||||
|
||||
await storage.setMetas([
|
||||
{ item: item1, meta: setMeta1 },
|
||||
{ item: item2, meta: setMeta2 },
|
||||
{ item: item3, meta: setMeta3 },
|
||||
]);
|
||||
|
||||
expect(await item1.getMeta()).toEqual(expectedMeta1);
|
||||
expect(await item2.getMeta()).toEqual(expectedMeta2);
|
||||
expect(await item3.getMeta()).toEqual(expectedMeta3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeItem', () => {
|
||||
it('should remove the key from storage', async () => {
|
||||
await fakeBrowser.storage[storageArea].set({ count: 456 });
|
||||
@@ -219,29 +356,136 @@ describe('Storage Utils', () => {
|
||||
});
|
||||
|
||||
describe('removeItems', () => {
|
||||
it('should remove multiple items', async () => {
|
||||
it('should remove multiple keys', async () => {
|
||||
const key1 = `${storageArea}:one` as const;
|
||||
const key2 = `${storageArea}:two` as const;
|
||||
const key3 = `${storageArea}:three` as const;
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
['one']: '1',
|
||||
['two']: null,
|
||||
['two$']: { v: 1 },
|
||||
['three']: '1',
|
||||
['three$']: { v: 1 },
|
||||
one: 1,
|
||||
two: 2,
|
||||
});
|
||||
|
||||
await storage.removeItems([key1, key2]);
|
||||
|
||||
expect(await storage.getItem(key1)).toBeNull();
|
||||
expect(await storage.getItem(key2)).toBeNull();
|
||||
});
|
||||
|
||||
it('should remove multiple keys and metadata when requested', async () => {
|
||||
const key1 = `${storageArea}:one` as const;
|
||||
const key2 = `${storageArea}:two` as const;
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
one: 1,
|
||||
one$: { v: 1 },
|
||||
two: 2,
|
||||
two$: { v: 1 },
|
||||
});
|
||||
|
||||
await storage.removeItems([
|
||||
key1,
|
||||
key2,
|
||||
{ key: key3, options: { removeMeta: true } },
|
||||
{ key: key2, options: { removeMeta: true } },
|
||||
]);
|
||||
|
||||
expect(await storage.getItem(key1)).toBeNull();
|
||||
expect(await storage.getMeta(key1)).toEqual({ v: 1 });
|
||||
expect(await storage.getItem(key2)).toBeNull();
|
||||
expect(await storage.getMeta(key2)).toEqual({ v: 1 });
|
||||
expect(await storage.getItem(key3)).toBeNull();
|
||||
expect(await storage.getMeta(key3)).toEqual({});
|
||||
expect(await storage.getMeta(key2)).toEqual({});
|
||||
});
|
||||
|
||||
it('should remove multiple items', async () => {
|
||||
const item1 = storage.defineItem(`${storageArea}:one`);
|
||||
const item2 = storage.defineItem(`${storageArea}:two`);
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
one: 1,
|
||||
two: 2,
|
||||
});
|
||||
|
||||
await storage.removeItems([item1, item2]);
|
||||
|
||||
expect(await item1.getValue()).toBeNull();
|
||||
expect(await item2.getValue()).toBeNull();
|
||||
});
|
||||
|
||||
it('should remove multiple items and metadata when requested', async () => {
|
||||
const item1 = storage.defineItem(`${storageArea}:one`);
|
||||
const item2 = storage.defineItem(`${storageArea}:two`);
|
||||
await fakeBrowser.storage[storageArea].set({
|
||||
one: 1,
|
||||
one$: { v: 1 },
|
||||
two: 2,
|
||||
two$: { v: 1 },
|
||||
});
|
||||
|
||||
await storage.removeItems([
|
||||
item1,
|
||||
{ item: item2, options: { removeMeta: true } },
|
||||
]);
|
||||
|
||||
expect(await item1.getValue()).toBeNull();
|
||||
expect(await item1.getMeta()).toEqual({ v: 1 });
|
||||
expect(await item2.getValue()).toBeNull();
|
||||
expect(await item2.getMeta()).toEqual({});
|
||||
});
|
||||
|
||||
it('should remove multiple items', async () => {
|
||||
const item1 = storage.defineItem(`${storageArea}:one`);
|
||||
const item2 = storage.defineItem(`${storageArea}:two`);
|
||||
|
||||
await fakeBrowser.storage.local.set({
|
||||
one: 1,
|
||||
two: 2,
|
||||
});
|
||||
|
||||
await storage.removeItems([item1, item2]);
|
||||
|
||||
expect(await item1.getValue()).toBeNull();
|
||||
expect(await item2.getValue()).toBeNull();
|
||||
});
|
||||
|
||||
it('should remove items using { item: WxtStorageItem, options?: RemoveItemOptions } objects', async () => {
|
||||
const item1 = storage.defineItem('local:item1');
|
||||
const item2 = storage.defineItem('session:item2');
|
||||
|
||||
await item1.setValue('value1');
|
||||
await item1.setMeta({ v: 1 });
|
||||
await item2.setValue('value2');
|
||||
await item2.setMeta({ v: 1 });
|
||||
|
||||
await storage.removeItems([
|
||||
{ item: item1, options: { removeMeta: true } },
|
||||
{ item: item2, options: { removeMeta: false } },
|
||||
]);
|
||||
|
||||
expect(await item1.getValue()).toBeNull();
|
||||
expect(await item1.getMeta()).toEqual({});
|
||||
expect(await item2.getValue()).toBeNull();
|
||||
expect(await item2.getMeta()).toEqual({ v: 1 });
|
||||
});
|
||||
|
||||
it('should handle a mix of different input types', async () => {
|
||||
const item1 = storage.defineItem('local:item1');
|
||||
const item2 = storage.defineItem('session:item2');
|
||||
const item3 = storage.defineItem('local:item3');
|
||||
|
||||
await item1.setValue('value1');
|
||||
await item2.setValue('value2');
|
||||
await item3.setValue('value3');
|
||||
|
||||
await storage.removeItems([
|
||||
'local:item1',
|
||||
item2,
|
||||
{ key: 'local:item3', options: { removeMeta: true } },
|
||||
]);
|
||||
|
||||
expect(await storage.getItem('local:item1')).toBeNull();
|
||||
expect(await item2.getValue()).toBeNull();
|
||||
expect(await item3.getValue()).toBeNull();
|
||||
expect(await item3.getMeta()).toEqual({});
|
||||
});
|
||||
|
||||
it('should not throw an error when removing non-existent items', async () => {
|
||||
const item = storage.defineItem('local:non_existent');
|
||||
|
||||
await expect(storage.removeItems([item])).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -400,6 +644,22 @@ describe('Storage Utils', () => {
|
||||
},
|
||||
);
|
||||
|
||||
describe('Invalid storage areas', () => {
|
||||
it('should not accept keys without a valid storage area prefix', async () => {
|
||||
// @ts-expect-error
|
||||
await storage.getItem('test').catch(() => {});
|
||||
// @ts-expect-error
|
||||
await storage.getItem('loca:test').catch(() => {});
|
||||
});
|
||||
|
||||
it('should throw an error when using an invalid storage area', async () => {
|
||||
// @ts-expect-error: Test passes if there is a type error here
|
||||
await expect(storage.getItem('invalidArea:key')).rejects.toThrow(
|
||||
'Invalid area',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defineItem', () => {
|
||||
describe('versioning', () => {
|
||||
it('should migrate values to the latest when a version upgrade is detected', async () => {
|
||||
@@ -553,6 +813,28 @@ describe('Storage Utils', () => {
|
||||
'Version downgrade detected (v2 -> v1) for "local:count"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error when defining an item with an invalid version', () => {
|
||||
expect(() => storage.defineItem('local:key', { version: 0 })).toThrow(
|
||||
'Storage item version cannot be less than 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors in migration functions', async () => {
|
||||
const cause = Error('Some error');
|
||||
const expectedError = new MigrationError('local:key', 2, { cause });
|
||||
const item = storage.defineItem<number>('local:key', {
|
||||
version: 3,
|
||||
migrations: {
|
||||
2: () => {
|
||||
throw cause;
|
||||
},
|
||||
},
|
||||
});
|
||||
await fakeBrowser.storage.local.set({ key: 1, key$: { v: 1 } });
|
||||
|
||||
await expect(item.migrate()).rejects.toThrow(expectedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValue', () => {
|
||||
@@ -872,6 +1154,24 @@ describe('Storage Utils', () => {
|
||||
|
||||
await expect(storage.getItem('local:test')).resolves.toBe(expected);
|
||||
});
|
||||
|
||||
it("should re-initialize a value on the next call to getValue if it's been removed", async () => {
|
||||
const init = vi.fn().mockImplementation(Math.random);
|
||||
const item = storage.defineItem<number>('local:key', { init });
|
||||
await waitForInit();
|
||||
|
||||
await item.removeValue();
|
||||
// Make sure it's actually blank before running the test
|
||||
expect(await chrome.storage.local.get()).toEqual({});
|
||||
init.mockClear();
|
||||
|
||||
const [value1, value2] = await Promise.all([
|
||||
item.getValue(),
|
||||
item.getValue(),
|
||||
]);
|
||||
expect(init).toBeCalledTimes(1);
|
||||
expect(value1).toBe(value2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('types', () => {
|
||||
@@ -896,12 +1196,173 @@ describe('Storage Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('types', () => {
|
||||
it('should not accept keys without a valid storage area prefix', async () => {
|
||||
// @ts-expect-error: Test passes if there is a type error here
|
||||
await storage.getItem('test').catch(() => {});
|
||||
// @ts-expect-error
|
||||
await storage.getItem('loca:test').catch(() => {});
|
||||
describe('Multiple Storage Areas', () => {
|
||||
describe('getItems', () => {
|
||||
it('should get the values of multiple storage items efficiently', async () => {
|
||||
const item1 = storage.defineItem<number>('local:item1');
|
||||
const item2 = storage.defineItem<string>('session:item2');
|
||||
const item3 = storage.defineItem<boolean>('local:item3');
|
||||
|
||||
await item1.setValue(42);
|
||||
await item2.setValue('hello');
|
||||
await item3.setValue(false);
|
||||
|
||||
const localGetSpy = vi.spyOn(fakeBrowser.storage.local, 'get');
|
||||
const sessionGetSpy = vi.spyOn(fakeBrowser.storage.session, 'get');
|
||||
|
||||
const values = await storage.getItems([item1, item2, item3]);
|
||||
|
||||
expect(values).toEqual([
|
||||
{
|
||||
key: 'local:item1',
|
||||
value: 42,
|
||||
},
|
||||
{
|
||||
key: 'session:item2',
|
||||
value: 'hello',
|
||||
},
|
||||
{
|
||||
key: 'local:item3',
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(localGetSpy).toBeCalledTimes(1);
|
||||
expect(localGetSpy).toBeCalledWith(['item1', 'item3']);
|
||||
expect(sessionGetSpy).toBeCalledTimes(1);
|
||||
expect(sessionGetSpy).toBeCalledWith(['item2']);
|
||||
});
|
||||
|
||||
it('should return values in the same order as input', async () => {
|
||||
const item1 = storage.defineItem<number>('local:item1');
|
||||
const item2 = storage.defineItem<string>('session:item2');
|
||||
const item3 = storage.defineItem<boolean>('local:item3');
|
||||
|
||||
expect(await storage.getItems([item1, item2, item3])).toEqual([
|
||||
{ key: item1.key, value: null },
|
||||
{ key: item2.key, value: null },
|
||||
{ key: item3.key, value: null },
|
||||
]);
|
||||
expect(await storage.getItems([item3, item2, item1])).toEqual([
|
||||
{ key: item3.key, value: null },
|
||||
{ key: item2.key, value: null },
|
||||
{ key: item1.key, value: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMetas', () => {
|
||||
it('should get the metadata of multiple storage items efficiently', async () => {
|
||||
const item1 = storage.defineItem<number, { v: number }>('local:item1');
|
||||
const item2 = storage.defineItem<string, { date: number }>(
|
||||
'session:item2',
|
||||
);
|
||||
const item3 = storage.defineItem<boolean, { flag: boolean }>(
|
||||
'local:item3',
|
||||
);
|
||||
|
||||
await item1.setMeta({ v: 1 });
|
||||
await item2.setMeta({ date: 1234567890 });
|
||||
// item3 has no meta
|
||||
|
||||
const localGetSpy = vi.spyOn(fakeBrowser.storage.local, 'get');
|
||||
const sessionGetSpy = vi.spyOn(fakeBrowser.storage.session, 'get');
|
||||
|
||||
const metas = await storage.getMetas([item1, item2, item3]);
|
||||
|
||||
expect(metas).toEqual([
|
||||
{ key: item1.key, meta: { v: 1 } },
|
||||
{ key: item2.key, meta: { date: 1234567890 } },
|
||||
{ key: item3.key, meta: {} },
|
||||
]);
|
||||
|
||||
expect(localGetSpy).toBeCalledTimes(1);
|
||||
expect(localGetSpy).toBeCalledWith(['item1$', 'item3$']);
|
||||
expect(sessionGetSpy).toBeCalledTimes(1);
|
||||
expect(sessionGetSpy).toBeCalledWith(['item2$']);
|
||||
});
|
||||
|
||||
it('should return the metadata in the same order as input', async () => {
|
||||
const item1 = storage.defineItem<number, { v: number }>('local:item1');
|
||||
const item2 = storage.defineItem<string, { date: number }>(
|
||||
'session:item2',
|
||||
);
|
||||
const item3 = storage.defineItem<boolean, { flag: boolean }>(
|
||||
'local:item3',
|
||||
);
|
||||
|
||||
expect(await storage.getMetas([item1, item2, item3])).toEqual([
|
||||
{ key: item1.key, meta: {} },
|
||||
{ key: item2.key, meta: {} },
|
||||
{ key: item3.key, meta: {} },
|
||||
]);
|
||||
expect(await storage.getMetas([item3, item2, item1])).toEqual([
|
||||
{ key: item3.key, meta: {} },
|
||||
{ key: item2.key, meta: {} },
|
||||
{ key: item1.key, meta: {} },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setItems', () => {
|
||||
it('should set the values of multiple storage items efficiently', async () => {
|
||||
const item1 = storage.defineItem<number>('local:item1');
|
||||
const value1 = 100;
|
||||
const item2 = storage.defineItem<string>('session:item2');
|
||||
const value2 = 'test';
|
||||
const item3 = storage.defineItem<boolean>('local:item3');
|
||||
const value3 = true;
|
||||
|
||||
const localSetSpy = vi.spyOn(fakeBrowser.storage.local, 'set');
|
||||
const sessionSetSpy = vi.spyOn(fakeBrowser.storage.session, 'set');
|
||||
|
||||
await storage.setItems([
|
||||
{ item: item1, value: value1 },
|
||||
{ item: item2, value: value2 },
|
||||
{ item: item3, value: value3 },
|
||||
]);
|
||||
|
||||
expect(localSetSpy).toBeCalledTimes(1);
|
||||
expect(localSetSpy).toBeCalledWith({ item1: value1, item3: value3 });
|
||||
expect(sessionSetSpy).toBeCalledTimes(1);
|
||||
expect(sessionSetSpy).toBeCalledWith({ item2: value2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMetas', () => {
|
||||
it('should set metadata efficiently', async () => {
|
||||
const item1 = storage.defineItem<number, { v: number }>('local:one');
|
||||
const item2 = storage.defineItem<string, { v: number }>('session:two');
|
||||
const item3 = storage.defineItem<boolean, { v: number }>('local:three');
|
||||
await waitForInit();
|
||||
|
||||
const localGetSpy = vi.spyOn(fakeBrowser.storage.local, 'get');
|
||||
const sessionGetSpy = vi.spyOn(fakeBrowser.storage.session, 'get');
|
||||
const localSetSpy = vi.spyOn(fakeBrowser.storage.local, 'set');
|
||||
const sessionSetSpy = vi.spyOn(fakeBrowser.storage.session, 'set');
|
||||
|
||||
await storage.setMetas([
|
||||
{ item: item1, meta: { v: 1 } },
|
||||
{ item: item2, meta: { v: 2 } },
|
||||
{ item: item3, meta: { v: 3 } },
|
||||
]);
|
||||
|
||||
console.log(localGetSpy.mock.calls);
|
||||
expect(localGetSpy).toBeCalledTimes(1);
|
||||
expect(localGetSpy).toBeCalledWith(['one$', 'three$']);
|
||||
expect(sessionGetSpy).toBeCalledTimes(1);
|
||||
expect(sessionGetSpy).toBeCalledWith(['two$']);
|
||||
|
||||
expect(localSetSpy).toBeCalledTimes(1);
|
||||
expect(localSetSpy).toBeCalledWith({
|
||||
one$: { v: 1 },
|
||||
three$: { v: 3 },
|
||||
});
|
||||
expect(sessionSetSpy).toBeCalledTimes(1);
|
||||
expect(sessionSetSpy).toBeCalledWith({
|
||||
two$: { v: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a nullable type when getItem is called without a fallback', async () => {
|
||||
@@ -0,0 +1,878 @@
|
||||
/// <reference types="chrome" />
|
||||
/**
|
||||
* Simplified storage APIs with support for versioned fields, snapshots, metadata, and item definitions.
|
||||
*
|
||||
* See [the guide](https://wxt.dev/storage.html) for more information.
|
||||
* @module @wxt-dev/storage
|
||||
*/
|
||||
import { dequal } from 'dequal/lite';
|
||||
import { Mutex } from 'async-mutex';
|
||||
|
||||
const browser: typeof chrome =
|
||||
// @ts-expect-error
|
||||
globalThis.browser?.runtime?.id == null
|
||||
? globalThis.chrome
|
||||
: // @ts-expect-error
|
||||
globalThis.browser;
|
||||
|
||||
export const storage = createStorage();
|
||||
|
||||
function createStorage(): WxtStorage {
|
||||
const drivers: Record<StorageArea, WxtStorageDriver> = {
|
||||
local: createDriver('local'),
|
||||
session: createDriver('session'),
|
||||
sync: createDriver('sync'),
|
||||
managed: createDriver('managed'),
|
||||
};
|
||||
const getDriver = (area: StorageArea) => {
|
||||
const driver = drivers[area];
|
||||
if (driver == null) {
|
||||
const areaNames = Object.keys(drivers).join(', ');
|
||||
throw Error(`Invalid area "${area}". Options: ${areaNames}`);
|
||||
}
|
||||
return driver;
|
||||
};
|
||||
const resolveKey = (key: StorageItemKey) => {
|
||||
const deliminatorIndex = key.indexOf(':');
|
||||
const driverArea = key.substring(0, deliminatorIndex) as StorageArea;
|
||||
const driverKey = key.substring(deliminatorIndex + 1);
|
||||
if (driverKey == null)
|
||||
throw Error(
|
||||
`Storage key should be in the form of "area:key", but received "${key}"`,
|
||||
);
|
||||
|
||||
return {
|
||||
driverArea,
|
||||
driverKey,
|
||||
driver: getDriver(driverArea),
|
||||
};
|
||||
};
|
||||
const getMetaKey = (key: string) => key + '$';
|
||||
const mergeMeta = (oldMeta: any, newMeta: any): any => {
|
||||
const newFields = { ...oldMeta };
|
||||
Object.entries(newMeta).forEach(([key, value]) => {
|
||||
if (value == null) delete newFields[key];
|
||||
else newFields[key] = value;
|
||||
});
|
||||
return newFields;
|
||||
};
|
||||
const getValueOrFallback = (value: any, fallback: any) =>
|
||||
value ?? fallback ?? null;
|
||||
const getMetaValue = (properties: any) =>
|
||||
typeof properties === 'object' && !Array.isArray(properties)
|
||||
? properties
|
||||
: {};
|
||||
|
||||
const getItem = async (
|
||||
driver: WxtStorageDriver,
|
||||
driverKey: string,
|
||||
opts: GetItemOptions<any> | undefined,
|
||||
) => {
|
||||
const res = await driver.getItem<any>(driverKey);
|
||||
return getValueOrFallback(res, opts?.fallback ?? opts?.defaultValue);
|
||||
};
|
||||
const getMeta = async (driver: WxtStorageDriver, driverKey: string) => {
|
||||
const metaKey = getMetaKey(driverKey);
|
||||
const res = await driver.getItem<any>(metaKey);
|
||||
return getMetaValue(res);
|
||||
};
|
||||
const setItem = async (
|
||||
driver: WxtStorageDriver,
|
||||
driverKey: string,
|
||||
value: any,
|
||||
) => {
|
||||
await driver.setItem(driverKey, value ?? null);
|
||||
};
|
||||
const setMeta = async (
|
||||
driver: WxtStorageDriver,
|
||||
driverKey: string,
|
||||
properties: any | undefined,
|
||||
) => {
|
||||
const metaKey = getMetaKey(driverKey);
|
||||
const existingFields = getMetaValue(await driver.getItem(metaKey));
|
||||
await driver.setItem(metaKey, mergeMeta(existingFields, properties));
|
||||
};
|
||||
const removeItem = async (
|
||||
driver: WxtStorageDriver,
|
||||
driverKey: string,
|
||||
opts: RemoveItemOptions | undefined,
|
||||
) => {
|
||||
await driver.removeItem(driverKey);
|
||||
if (opts?.removeMeta) {
|
||||
const metaKey = getMetaKey(driverKey);
|
||||
await driver.removeItem(metaKey);
|
||||
}
|
||||
};
|
||||
const removeMeta = async (
|
||||
driver: WxtStorageDriver,
|
||||
driverKey: string,
|
||||
properties: string | string[] | undefined,
|
||||
) => {
|
||||
const metaKey = getMetaKey(driverKey);
|
||||
if (properties == null) {
|
||||
await driver.removeItem(metaKey);
|
||||
} else {
|
||||
const newFields = getMetaValue(await driver.getItem(metaKey));
|
||||
[properties].flat().forEach((field) => delete newFields[field]);
|
||||
await driver.setItem(metaKey, newFields);
|
||||
}
|
||||
};
|
||||
const watch = (
|
||||
driver: WxtStorageDriver,
|
||||
driverKey: string,
|
||||
cb: WatchCallback<any>,
|
||||
) => {
|
||||
return driver.watch(driverKey, cb);
|
||||
};
|
||||
|
||||
const storage: WxtStorage = {
|
||||
getItem: async (key, opts) => {
|
||||
const { driver, driverKey } = resolveKey(key);
|
||||
return await getItem(driver, driverKey, opts);
|
||||
},
|
||||
getItems: async (keys) => {
|
||||
const areaToKeyMap = new Map<StorageArea, string[]>();
|
||||
const keyToOptsMap = new Map<string, GetItemOptions<any> | undefined>();
|
||||
const orderedKeys: StorageItemKey[] = [];
|
||||
|
||||
keys.forEach((key) => {
|
||||
let keyStr: StorageItemKey;
|
||||
let opts: GetItemOptions<any> | undefined;
|
||||
if (typeof key === 'string') {
|
||||
// key: string
|
||||
keyStr = key;
|
||||
} else if ('getValue' in key) {
|
||||
// key: WxtStorageItem
|
||||
keyStr = key.key;
|
||||
opts = { fallback: key.fallback };
|
||||
} else {
|
||||
// key: { key, options }
|
||||
keyStr = key.key;
|
||||
opts = key.options;
|
||||
}
|
||||
orderedKeys.push(keyStr);
|
||||
const { driverArea, driverKey } = resolveKey(keyStr);
|
||||
const areaKeys = areaToKeyMap.get(driverArea) ?? [];
|
||||
areaToKeyMap.set(driverArea, areaKeys.concat(driverKey));
|
||||
keyToOptsMap.set(keyStr, opts);
|
||||
});
|
||||
|
||||
const resultsMap = new Map<StorageItemKey, any>();
|
||||
await Promise.all(
|
||||
Array.from(areaToKeyMap.entries()).map(async ([driverArea, keys]) => {
|
||||
const driverResults = await drivers[driverArea].getItems(keys);
|
||||
driverResults.forEach((driverResult) => {
|
||||
const key = `${driverArea}:${driverResult.key}` as StorageItemKey;
|
||||
const opts = keyToOptsMap.get(key);
|
||||
const value = getValueOrFallback(
|
||||
driverResult.value,
|
||||
opts?.fallback ?? opts?.defaultValue,
|
||||
);
|
||||
resultsMap.set(key, value);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return orderedKeys.map((key) => ({
|
||||
key,
|
||||
value: resultsMap.get(key),
|
||||
}));
|
||||
},
|
||||
getMeta: async (key) => {
|
||||
const { driver, driverKey } = resolveKey(key);
|
||||
return await getMeta(driver, driverKey);
|
||||
},
|
||||
getMetas: async (args) => {
|
||||
const keys = args.map((arg) => {
|
||||
const key = typeof arg === 'string' ? arg : arg.key;
|
||||
const { driverArea, driverKey } = resolveKey(key);
|
||||
return {
|
||||
key,
|
||||
driverArea,
|
||||
driverKey,
|
||||
driverMetaKey: getMetaKey(driverKey),
|
||||
};
|
||||
});
|
||||
const areaToDriverMetaKeysMap = keys.reduce<
|
||||
Partial<Record<StorageArea, (typeof keys)[number][]>>
|
||||
>((map, key) => {
|
||||
map[key.driverArea] ??= [];
|
||||
map[key.driverArea]!.push(key);
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
const resultsMap: Record<string, any> = {};
|
||||
await Promise.all(
|
||||
Object.entries(areaToDriverMetaKeysMap).map(async ([area, keys]) => {
|
||||
const areaRes = await browser.storage[area as StorageArea].get(
|
||||
keys.map((key) => key.driverMetaKey),
|
||||
);
|
||||
keys.forEach((key) => {
|
||||
resultsMap[key.key] = areaRes[key.driverMetaKey] ?? {};
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return keys.map((key) => ({
|
||||
key: key.key,
|
||||
meta: resultsMap[key.key],
|
||||
}));
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
const { driver, driverKey } = resolveKey(key);
|
||||
await setItem(driver, driverKey, value);
|
||||
},
|
||||
setItems: async (items) => {
|
||||
const areaToKeyValueMap: Partial<
|
||||
Record<StorageArea, Array<{ key: string; value: any }>>
|
||||
> = {};
|
||||
items.forEach((item) => {
|
||||
const { driverArea, driverKey } = resolveKey(
|
||||
'key' in item ? item.key : item.item.key,
|
||||
);
|
||||
areaToKeyValueMap[driverArea] ??= [];
|
||||
areaToKeyValueMap[driverArea].push({
|
||||
key: driverKey,
|
||||
value: item.value,
|
||||
});
|
||||
});
|
||||
await Promise.all(
|
||||
Object.entries(areaToKeyValueMap).map(async ([driverArea, values]) => {
|
||||
const driver = getDriver(driverArea as StorageArea);
|
||||
await driver.setItems(values);
|
||||
}),
|
||||
);
|
||||
},
|
||||
setMeta: async (key, properties) => {
|
||||
const { driver, driverKey } = resolveKey(key);
|
||||
await setMeta(driver, driverKey, properties);
|
||||
},
|
||||
setMetas: async (items) => {
|
||||
const areaToMetaUpdatesMap: Partial<
|
||||
Record<StorageArea, { key: string; properties: any }[]>
|
||||
> = {};
|
||||
items.forEach((item) => {
|
||||
const { driverArea, driverKey } = resolveKey(
|
||||
'key' in item ? item.key : item.item.key,
|
||||
);
|
||||
areaToMetaUpdatesMap[driverArea] ??= [];
|
||||
areaToMetaUpdatesMap[driverArea].push({
|
||||
key: driverKey,
|
||||
properties: item.meta,
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(areaToMetaUpdatesMap).map(
|
||||
async ([storageArea, updates]) => {
|
||||
const driver = getDriver(storageArea as StorageArea);
|
||||
const metaKeys = updates.map(({ key }) => getMetaKey(key));
|
||||
console.log(storageArea, metaKeys);
|
||||
const existingMetas = await driver.getItems(metaKeys);
|
||||
const existingMetaMap = Object.fromEntries(
|
||||
existingMetas.map(({ key, value }) => [key, getMetaValue(value)]),
|
||||
);
|
||||
|
||||
const metaUpdates = updates.map(({ key, properties }) => {
|
||||
const metaKey = getMetaKey(key);
|
||||
return {
|
||||
key: metaKey,
|
||||
value: mergeMeta(existingMetaMap[metaKey] ?? {}, properties),
|
||||
};
|
||||
});
|
||||
|
||||
await driver.setItems(metaUpdates);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
removeItem: async (key, opts) => {
|
||||
const { driver, driverKey } = resolveKey(key);
|
||||
await removeItem(driver, driverKey, opts);
|
||||
},
|
||||
removeItems: async (keys) => {
|
||||
const areaToKeysMap: Partial<Record<StorageArea, string[]>> = {};
|
||||
|
||||
keys.forEach((key) => {
|
||||
let keyStr: StorageItemKey;
|
||||
let opts: RemoveItemOptions | undefined;
|
||||
if (typeof key === 'string') {
|
||||
// key: string
|
||||
keyStr = key;
|
||||
} else if ('getValue' in key) {
|
||||
// key: WxtStorageItem
|
||||
keyStr = key.key;
|
||||
} else if ('item' in key) {
|
||||
// key: { item, options }
|
||||
keyStr = key.item.key;
|
||||
opts = key.options;
|
||||
} else {
|
||||
// key: { key, options }
|
||||
keyStr = key.key;
|
||||
opts = key.options;
|
||||
}
|
||||
const { driverArea, driverKey } = resolveKey(keyStr);
|
||||
areaToKeysMap[driverArea] ??= [];
|
||||
areaToKeysMap[driverArea].push(driverKey);
|
||||
if (opts?.removeMeta) {
|
||||
areaToKeysMap[driverArea].push(getMetaKey(driverKey));
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(areaToKeysMap).map(async ([driverArea, keys]) => {
|
||||
const driver = getDriver(driverArea as StorageArea);
|
||||
await driver.removeItems(keys);
|
||||
}),
|
||||
);
|
||||
},
|
||||
removeMeta: async (key, properties) => {
|
||||
const { driver, driverKey } = resolveKey(key);
|
||||
await removeMeta(driver, driverKey, properties);
|
||||
},
|
||||
snapshot: async (base, opts) => {
|
||||
const driver = getDriver(base);
|
||||
const data = await driver.snapshot();
|
||||
opts?.excludeKeys?.forEach((key) => {
|
||||
delete data[key];
|
||||
delete data[getMetaKey(key)];
|
||||
});
|
||||
return data;
|
||||
},
|
||||
restoreSnapshot: async (base, data) => {
|
||||
const driver = getDriver(base);
|
||||
await driver.restoreSnapshot(data);
|
||||
},
|
||||
watch: (key, cb) => {
|
||||
const { driver, driverKey } = resolveKey(key);
|
||||
return watch(driver, driverKey, cb);
|
||||
},
|
||||
unwatch() {
|
||||
Object.values(drivers).forEach((driver) => {
|
||||
driver.unwatch();
|
||||
});
|
||||
},
|
||||
defineItem: (key, opts?: WxtStorageItemOptions<any>) => {
|
||||
const { driver, driverKey } = resolveKey(key);
|
||||
|
||||
const { version: targetVersion = 1, migrations = {} } = opts ?? {};
|
||||
if (targetVersion < 1) {
|
||||
throw Error(
|
||||
'Storage item version cannot be less than 1. Initial versions should be set to 1, not 0.',
|
||||
);
|
||||
}
|
||||
const migrate = async () => {
|
||||
const driverMetaKey = getMetaKey(driverKey);
|
||||
const [{ value }, { value: meta }] = await driver.getItems([
|
||||
driverKey,
|
||||
driverMetaKey,
|
||||
]);
|
||||
if (value == null) return;
|
||||
|
||||
const currentVersion = meta?.v ?? 1;
|
||||
if (currentVersion > targetVersion) {
|
||||
throw Error(
|
||||
`Version downgrade detected (v${currentVersion} -> v${targetVersion}) for "${key}"`,
|
||||
);
|
||||
}
|
||||
|
||||
console.debug(
|
||||
`[@wxt-dev/storage] Running storage migration for ${key}: v${currentVersion} -> v${targetVersion}`,
|
||||
);
|
||||
const migrationsToRun = Array.from(
|
||||
{ length: targetVersion - currentVersion },
|
||||
(_, i) => currentVersion + i + 1,
|
||||
);
|
||||
let migratedValue = value;
|
||||
for (const migrateToVersion of migrationsToRun) {
|
||||
try {
|
||||
migratedValue =
|
||||
(await migrations?.[migrateToVersion]?.(migratedValue)) ??
|
||||
migratedValue;
|
||||
} catch (err) {
|
||||
throw Error(`v${migrateToVersion} migration failed for "${key}"`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
await driver.setItems([
|
||||
{ key: driverKey, value: migratedValue },
|
||||
{ key: driverMetaKey, value: { ...meta, v: targetVersion } },
|
||||
]);
|
||||
console.debug(
|
||||
`[@wxt-dev/storage] Storage migration completed for ${key} v${targetVersion}`,
|
||||
{ migratedValue },
|
||||
);
|
||||
};
|
||||
const migrationsDone =
|
||||
opts?.migrations == null
|
||||
? Promise.resolve()
|
||||
: migrate().catch((err) => {
|
||||
console.error(
|
||||
`[@wxt-dev/storage] Migration failed for ${key}`,
|
||||
err,
|
||||
);
|
||||
});
|
||||
|
||||
const initMutex = new Mutex();
|
||||
|
||||
const getFallback = () => opts?.fallback ?? opts?.defaultValue ?? null;
|
||||
|
||||
const getOrInitValue = () =>
|
||||
initMutex.runExclusive(async () => {
|
||||
const value = await driver.getItem<any>(driverKey);
|
||||
// Don't init value if it already exists or the init function isn't provided
|
||||
if (value != null || opts?.init == null) return value;
|
||||
|
||||
const newValue = await opts.init();
|
||||
await driver.setItem<any>(driverKey, newValue);
|
||||
return newValue;
|
||||
});
|
||||
|
||||
// Initialize the value once migrations have finished
|
||||
migrationsDone.then(getOrInitValue);
|
||||
|
||||
return {
|
||||
key,
|
||||
get defaultValue() {
|
||||
return getFallback();
|
||||
},
|
||||
get fallback() {
|
||||
return getFallback();
|
||||
},
|
||||
getValue: async () => {
|
||||
await migrationsDone;
|
||||
if (opts?.init) {
|
||||
return await getOrInitValue();
|
||||
} else {
|
||||
return await getItem(driver, driverKey, opts);
|
||||
}
|
||||
},
|
||||
getMeta: async () => {
|
||||
await migrationsDone;
|
||||
return await getMeta(driver, driverKey);
|
||||
},
|
||||
setValue: async (value) => {
|
||||
await migrationsDone;
|
||||
return await setItem(driver, driverKey, value);
|
||||
},
|
||||
setMeta: async (properties) => {
|
||||
await migrationsDone;
|
||||
return await setMeta(driver, driverKey, properties);
|
||||
},
|
||||
removeValue: async (opts) => {
|
||||
await migrationsDone;
|
||||
return await removeItem(driver, driverKey, opts);
|
||||
},
|
||||
removeMeta: async (properties) => {
|
||||
await migrationsDone;
|
||||
return await removeMeta(driver, driverKey, properties);
|
||||
},
|
||||
watch: (cb) =>
|
||||
watch(driver, driverKey, (newValue, oldValue) =>
|
||||
cb(newValue ?? getFallback(), oldValue ?? getFallback()),
|
||||
),
|
||||
migrate,
|
||||
};
|
||||
},
|
||||
};
|
||||
return storage;
|
||||
}
|
||||
|
||||
function createDriver(storageArea: StorageArea): WxtStorageDriver {
|
||||
const getStorageArea = () => {
|
||||
if (browser.runtime == null) {
|
||||
throw Error(
|
||||
[
|
||||
"'wxt/storage' must be loaded in a web extension environment",
|
||||
'\n - If thrown during a build, see https://github.com/wxt-dev/wxt/issues/371',
|
||||
" - If thrown during tests, mock 'wxt/browser' correctly. See https://wxt.dev/guide/go-further/testing.html\n",
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
if (browser.storage == null) {
|
||||
throw Error(
|
||||
"You must add the 'storage' permission to your manifest to use 'wxt/storage'",
|
||||
);
|
||||
}
|
||||
|
||||
const area = browser.storage[storageArea];
|
||||
if (area == null)
|
||||
throw Error(`"browser.storage.${storageArea}" is undefined`);
|
||||
return area;
|
||||
};
|
||||
const watchListeners = new Set<(changes: StorageAreaChanges) => void>();
|
||||
return {
|
||||
getItem: async (key) => {
|
||||
const res = await getStorageArea().get(key);
|
||||
return res[key];
|
||||
},
|
||||
getItems: async (keys) => {
|
||||
const result = await getStorageArea().get(keys);
|
||||
return keys.map((key) => ({ key, value: result[key] ?? null }));
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
if (value == null) {
|
||||
await getStorageArea().remove(key);
|
||||
} else {
|
||||
await getStorageArea().set({ [key]: value });
|
||||
}
|
||||
},
|
||||
setItems: async (values) => {
|
||||
const map = values.reduce<Record<string, unknown>>(
|
||||
(map, { key, value }) => {
|
||||
map[key] = value;
|
||||
return map;
|
||||
},
|
||||
{},
|
||||
);
|
||||
await getStorageArea().set(map);
|
||||
},
|
||||
removeItem: async (key) => {
|
||||
await getStorageArea().remove(key);
|
||||
},
|
||||
removeItems: async (keys) => {
|
||||
await getStorageArea().remove(keys);
|
||||
},
|
||||
snapshot: async () => {
|
||||
return await getStorageArea().get();
|
||||
},
|
||||
restoreSnapshot: async (data) => {
|
||||
await getStorageArea().set(data);
|
||||
},
|
||||
watch(key, cb) {
|
||||
const listener = (changes: StorageAreaChanges) => {
|
||||
const change = changes[key];
|
||||
if (change == null) return;
|
||||
if (dequal(change.newValue, change.oldValue)) return;
|
||||
cb(change.newValue ?? null, change.oldValue ?? null);
|
||||
};
|
||||
getStorageArea().onChanged.addListener(listener);
|
||||
watchListeners.add(listener);
|
||||
return () => {
|
||||
getStorageArea().onChanged.removeListener(listener);
|
||||
watchListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
unwatch() {
|
||||
watchListeners.forEach((listener) => {
|
||||
getStorageArea().onChanged.removeListener(listener);
|
||||
});
|
||||
watchListeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface WxtStorage {
|
||||
/**
|
||||
* Get an item from storage, or return `null` if it doesn't exist.
|
||||
*
|
||||
* @example
|
||||
* await storage.getItem<number>("local:installDate");
|
||||
*/
|
||||
getItem<TValue>(
|
||||
key: StorageItemKey,
|
||||
opts: GetItemOptions<TValue> & { fallback: TValue },
|
||||
): Promise<TValue>;
|
||||
|
||||
getItem<TValue>(
|
||||
key: StorageItemKey,
|
||||
opts?: GetItemOptions<TValue>,
|
||||
): Promise<TValue | null>;
|
||||
|
||||
/**
|
||||
* Get multiple items from storage. The return order is guaranteed to be the same as the order
|
||||
* requested.
|
||||
*
|
||||
* @example
|
||||
* await storage.getItems(["local:installDate", "session:someCounter"]);
|
||||
*/
|
||||
getItems(
|
||||
keys: Array<
|
||||
| StorageItemKey
|
||||
| WxtStorageItem<any, any>
|
||||
| { key: StorageItemKey; options?: GetItemOptions<any> }
|
||||
>,
|
||||
): Promise<Array<{ key: StorageItemKey; value: any }>>;
|
||||
/**
|
||||
* Return an object containing metadata about the key. Object is stored at `key + "$"`. If value
|
||||
* is not an object, it returns an empty object.
|
||||
*
|
||||
* @example
|
||||
* await storage.getMeta("local:installDate");
|
||||
*/
|
||||
getMeta<T extends Record<string, unknown>>(key: StorageItemKey): Promise<T>;
|
||||
/**
|
||||
* Get the metadata of multiple storage items.
|
||||
*
|
||||
* @param items List of keys or items to get the metadata of.
|
||||
* @returns An array containing storage keys and their metadata.
|
||||
*/
|
||||
getMetas(
|
||||
keys: Array<StorageItemKey | WxtStorageItem<any, any>>,
|
||||
): Promise<Array<{ key: StorageItemKey; meta: any }>>;
|
||||
/**
|
||||
* Set a value in storage. Setting a value to `null` or `undefined` is equivalent to calling
|
||||
* `removeItem`.
|
||||
*
|
||||
* @example
|
||||
* await storage.setItem<number>("local:installDate", Date.now());
|
||||
*/
|
||||
setItem<T>(key: StorageItemKey, value: T | null): Promise<void>;
|
||||
/**
|
||||
* Set multiple values in storage. If a value is set to `null` or `undefined`, the key is removed.
|
||||
*
|
||||
* @example
|
||||
* await storage.setItem([
|
||||
* { key: "local:installDate", value: Date.now() },
|
||||
* { key: "session:someCounter, value: 5 },
|
||||
* ]);
|
||||
*/
|
||||
setItems(
|
||||
values: Array<
|
||||
| { key: StorageItemKey; value: any }
|
||||
| { item: WxtStorageItem<any, any>; value: any }
|
||||
>,
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Sets metadata properties. If some properties are already set, but are not included in the
|
||||
* `properties` parameter, they will not be removed.
|
||||
*
|
||||
* @example
|
||||
* await storage.setMeta("local:installDate", { appVersion });
|
||||
*/
|
||||
setMeta<T extends Record<string, unknown>>(
|
||||
key: StorageItemKey,
|
||||
properties: T | null,
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Set the metadata of multiple storage items.
|
||||
*
|
||||
* @param items List of storage keys or items and metadata to set for each.
|
||||
*/
|
||||
setMetas(
|
||||
metas: Array<
|
||||
| { key: StorageItemKey; meta: Record<string, any> }
|
||||
| { item: WxtStorageItem<any, any>; meta: Record<string, any> }
|
||||
>,
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Removes an item from storage.
|
||||
*
|
||||
* @example
|
||||
* await storage.removeItem("local:installDate");
|
||||
*/
|
||||
removeItem(key: StorageItemKey, opts?: RemoveItemOptions): Promise<void>;
|
||||
/**
|
||||
* Remove a list of keys from storage.
|
||||
*/
|
||||
removeItems(
|
||||
keys: Array<
|
||||
| StorageItemKey
|
||||
| WxtStorageItem<any, any>
|
||||
| { key: StorageItemKey; options?: RemoveItemOptions }
|
||||
| { item: WxtStorageItem<any, any>; options?: RemoveItemOptions }
|
||||
>,
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Remove the entire metadata for a key, or specific properties by name.
|
||||
*
|
||||
* @example
|
||||
* // Remove all metadata properties from the item
|
||||
* await storage.removeMeta("local:installDate");
|
||||
*
|
||||
* // Remove only specific the "v" field
|
||||
* await storage.removeMeta("local:installDate", "v")
|
||||
*/
|
||||
removeMeta(
|
||||
key: StorageItemKey,
|
||||
properties?: string | string[],
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Return all the items in storage.
|
||||
*/
|
||||
snapshot(
|
||||
base: StorageArea,
|
||||
opts?: SnapshotOptions,
|
||||
): Promise<Record<string, unknown>>;
|
||||
/**
|
||||
* Restores the results of `snapshot`. If new properties have been saved since the snapshot, they are
|
||||
* not overridden. Only values existing in the snapshot are overridden.
|
||||
*/
|
||||
restoreSnapshot(base: StorageArea, data: any): Promise<void>;
|
||||
/**
|
||||
* Watch for changes to a specific key in storage.
|
||||
*/
|
||||
watch<T>(key: StorageItemKey, cb: WatchCallback<T | null>): Unwatch;
|
||||
/**
|
||||
* Remove all watch listeners.
|
||||
*/
|
||||
unwatch(): void;
|
||||
|
||||
/**
|
||||
* Define a storage item with a default value, type, or versioning.
|
||||
*
|
||||
* Read full docs: https://wxt.dev/guide/extension-apis/storage.html#defining-storage-items
|
||||
*/
|
||||
defineItem<TValue, TMetadata extends Record<string, unknown> = {}>(
|
||||
key: StorageItemKey,
|
||||
): WxtStorageItem<TValue | null, TMetadata>;
|
||||
defineItem<TValue, TMetadata extends Record<string, unknown> = {}>(
|
||||
key: StorageItemKey,
|
||||
options: WxtStorageItemOptions<TValue>,
|
||||
): WxtStorageItem<TValue, TMetadata>;
|
||||
}
|
||||
|
||||
interface WxtStorageDriver {
|
||||
getItem<T>(key: string): Promise<T | null>;
|
||||
getItems(keys: string[]): Promise<{ key: string; value: any }[]>;
|
||||
setItem<T>(key: string, value: T | null): Promise<void>;
|
||||
setItems(values: Array<{ key: string; value: any }>): Promise<void>;
|
||||
removeItem(key: string): Promise<void>;
|
||||
removeItems(keys: string[]): Promise<void>;
|
||||
snapshot(): Promise<Record<string, unknown>>;
|
||||
restoreSnapshot(data: Record<string, unknown>): Promise<void>;
|
||||
watch<T>(key: string, cb: WatchCallback<T | null>): Unwatch;
|
||||
unwatch(): void;
|
||||
}
|
||||
|
||||
export interface WxtStorageItem<
|
||||
TValue,
|
||||
TMetadata extends Record<string, unknown>,
|
||||
> {
|
||||
/**
|
||||
* The storage key passed when creating the storage item.
|
||||
*/
|
||||
key: StorageItemKey;
|
||||
/**
|
||||
* @deprecated Renamed to fallback, use it instead.
|
||||
*/
|
||||
defaultValue: TValue;
|
||||
/**
|
||||
* The value provided by the `fallback` option.
|
||||
*/
|
||||
fallback: TValue;
|
||||
/**
|
||||
* Get the latest value from storage.
|
||||
*/
|
||||
getValue(): Promise<TValue>;
|
||||
/**
|
||||
* Get metadata.
|
||||
*/
|
||||
getMeta(): Promise<NullablePartial<TMetadata>>;
|
||||
/**
|
||||
* Set the value in storage.
|
||||
*/
|
||||
setValue(value: TValue): Promise<void>;
|
||||
/**
|
||||
* Set metadata properties.
|
||||
*/
|
||||
setMeta(properties: NullablePartial<TMetadata>): Promise<void>;
|
||||
/**
|
||||
* Remove the value from storage.
|
||||
*/
|
||||
removeValue(opts?: RemoveItemOptions): Promise<void>;
|
||||
/**
|
||||
* Remove all metadata or certain properties from metadata.
|
||||
*/
|
||||
removeMeta(properties?: string[]): Promise<void>;
|
||||
/**
|
||||
* Listen for changes to the value in storage.
|
||||
*/
|
||||
watch(cb: WatchCallback<TValue>): Unwatch;
|
||||
/**
|
||||
* If there are migrations defined on the storage item, migrate to the latest version.
|
||||
*
|
||||
* **This function is ran automatically whenever the extension updates**, so you don't have to call it
|
||||
* manually.
|
||||
*/
|
||||
migrate(): Promise<void>;
|
||||
}
|
||||
|
||||
export type StorageArea = 'local' | 'session' | 'sync' | 'managed';
|
||||
export type StorageItemKey = `${StorageArea}:${string}`;
|
||||
|
||||
export interface GetItemOptions<T> {
|
||||
/**
|
||||
* @deprecated Renamed to `fallback`, use it instead.
|
||||
*/
|
||||
defaultValue?: T;
|
||||
/**
|
||||
* Default value returned when `getItem` would otherwise return `null`.
|
||||
*/
|
||||
fallback?: T;
|
||||
}
|
||||
|
||||
export interface RemoveItemOptions {
|
||||
/**
|
||||
* Optionally remove metadata when deleting a key.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
removeMeta?: boolean;
|
||||
}
|
||||
|
||||
export interface SnapshotOptions {
|
||||
/**
|
||||
* Exclude a list of keys. The storage area prefix should be removed since the snapshot is for a
|
||||
* specific storage area already.
|
||||
*/
|
||||
excludeKeys?: string[];
|
||||
}
|
||||
|
||||
export interface WxtStorageItemOptions<T> {
|
||||
/**
|
||||
* @deprecated Renamed to `fallback`, use it instead.
|
||||
*/
|
||||
defaultValue?: T;
|
||||
/**
|
||||
* Default value returned when `getValue` would otherwise return `null`.
|
||||
*/
|
||||
fallback?: T;
|
||||
/**
|
||||
* If passed, a value in storage will be initialized immediately after
|
||||
* defining the storage item. This function returns the value that will be
|
||||
* saved to storage during the initialization process if a value doesn't
|
||||
* already exist.
|
||||
*/
|
||||
init?: () => T | Promise<T>;
|
||||
/**
|
||||
* Provide a version number for the storage item to enable migrations. When changing the version
|
||||
* in the future, migration functions will be ran on application startup.
|
||||
*/
|
||||
version?: number;
|
||||
/**
|
||||
* A map of version numbers to the functions used to migrate the data to that version.
|
||||
*/
|
||||
migrations?: Record<number, (oldValue: any) => any>;
|
||||
}
|
||||
|
||||
export type StorageAreaChanges = {
|
||||
[key: string]: chrome.storage.StorageChange;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same as `Partial`, but includes `| null`. It makes all the properties of an object optional and
|
||||
* nullable.
|
||||
*/
|
||||
type NullablePartial<T> = {
|
||||
[key in keyof T]+?: T[key] | undefined | null;
|
||||
};
|
||||
/**
|
||||
* Callback called when a value in storage is changed.
|
||||
*/
|
||||
export type WatchCallback<T> = (newValue: T, oldValue: T) => void;
|
||||
/**
|
||||
* Call to remove a watch listener
|
||||
*/
|
||||
export type Unwatch = () => void;
|
||||
|
||||
export class MigrationError extends Error {
|
||||
constructor(
|
||||
public key: string,
|
||||
public version: number,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`v${version} migration failed for "${key}"`, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["chrome"]
|
||||
},
|
||||
"exclude": ["node_modules/**", "dist/**"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineProject } from 'vitest/config';
|
||||
|
||||
export default defineProject({
|
||||
test: {
|
||||
mockReset: true,
|
||||
restoreMocks: true,
|
||||
setupFiles: ['vitest.setup.ts'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { fakeBrowser } from '@webext-core/fake-browser';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
vi.stubGlobal('chrome', fakeBrowser);
|
||||
@@ -44,4 +44,4 @@ export default defineConfig({
|
||||
});
|
||||
```
|
||||
|
||||
Options have JSDocs available in your editor, or you can read them in the source code: [`UnoCSSOptions`](./src/index.ts).
|
||||
Options have JSDocs available in your editor, or you can read them in the source code: [`UnoCSSOptions`](https://github.com/wxt-dev/wxt/blob/main/packages/auto-icons/src/index.ts).
|
||||
|
||||
@@ -41,11 +41,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aklinker1/check": "^1.4.5",
|
||||
"oxlint": "^0.9.9",
|
||||
"publint": "^0.2.11",
|
||||
"typescript": "^5.6.2",
|
||||
"oxlint": "^0.11.1",
|
||||
"publint": "^0.2.12",
|
||||
"typescript": "^5.6.3",
|
||||
"unbuild": "^2.0.0",
|
||||
"unocss": "^0.63.3",
|
||||
"unocss": "^0.64.0",
|
||||
"wxt": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -28,7 +28,7 @@ export default defineWxtModule<UnoCSSOptions>({
|
||||
}
|
||||
|
||||
wxt.hooks.hook('vite:devServer:extendConfig', (config) => {
|
||||
config.plugins?.push(UnoCSS());
|
||||
config.plugins?.push(UnoCSS(resolvedOptions.configOrPath));
|
||||
});
|
||||
|
||||
wxt.hooks.hook('vite:build:extendConfig', async (entries, config) => {
|
||||
|
||||
@@ -22,15 +22,15 @@
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.269",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/chrome": "^0.0.280",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@wxt-dev/auto-icons": "workspace:*",
|
||||
"@wxt-dev/unocss": "workspace:*",
|
||||
"sass": "^1.79.4",
|
||||
"typescript": "^5.6.2",
|
||||
"unocss": "^0.63.3",
|
||||
"vitest": "^2.1.2",
|
||||
"sass": "^1.80.7",
|
||||
"typescript": "^5.6.3",
|
||||
"unocss": "^0.64.0",
|
||||
"vitest": "^2.1.4",
|
||||
"vitest-plugin-random-seed": "^1.1.0",
|
||||
"wxt": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -14,12 +14,13 @@ export default defineBackground({
|
||||
|
||||
console.log(useAppConfig());
|
||||
|
||||
// @ts-expect-error: should only accept entrypoints or public assets
|
||||
browser.runtime.getURL('/');
|
||||
browser.runtime.getURL('/background.js');
|
||||
browser.runtime.getURL('/icons/128.png');
|
||||
browser.runtime.getURL('/example.html#hash');
|
||||
browser.runtime.getURL('/example.html?query=param');
|
||||
// @ts-expect-error: should only accept entrypoints or public assets
|
||||
browser.runtime.getURL('/unknown');
|
||||
// @ts-expect-error: should only allow hashes/query params on HTML files
|
||||
browser.runtime.getURL('/icon-128.png?query=param');
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export default defineConfig({
|
||||
},
|
||||
example: {
|
||||
a: 'a',
|
||||
// @ts-expect-error: c is not defined, this should error out
|
||||
// @ts-expect-error: c is not defined, this should be a type error, but it should show up in the module
|
||||
c: 'c',
|
||||
},
|
||||
unocss: {
|
||||
|
||||
@@ -1,5 +1,151 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.19
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.18...wxt-v0.19.19)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Keyboard shortcut to reopen the browser without restarting the dev command ([#1211](https://github.com/wxt-dev/wxt/pull/1211))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Prevent changing dev server port when reloading config ([#1241](https://github.com/wxt-dev/wxt/pull/1241))
|
||||
- Ensure content scripts are registered immediately in dev mode ([#1253](https://github.com/wxt-dev/wxt/pull/1253))
|
||||
- Exclude skipped entrypoints from Firefox sources zip ([#1238](https://github.com/wxt-dev/wxt/pull/1238))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Nishu ([@nishu-murmu](http://github.com/nishu-murmu))
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
|
||||
## v0.19.18
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.17...wxt-v0.19.18)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Correct `dev.reloadCommand` restriction to consider only commands with suggested keys ([#1226](https://github.com/wxt-dev/wxt/pull/1226))
|
||||
|
||||
### 🌊 Types
|
||||
|
||||
- Add overloads to `ContentScriptContext#addEventListener` for different targets ([#1245](https://github.com/wxt-dev/wxt/pull/1245))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Refactor `findEntrypoints` to return all entrypoints with `skipped` set properly ([#1244](https://github.com/wxt-dev/wxt/pull/1244))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
- Jaguar Zhou ([@aiktb](http://github.com/aiktb))
|
||||
|
||||
## v0.19.17
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.16...wxt-v0.19.17)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- New `server:created`, `server:started`, and `server:closed` hooks ([#1179](https://github.com/wxt-dev/wxt/pull/1179))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Re-initialize WXT modules correctly during development ([#1176](https://github.com/wxt-dev/wxt/pull/1176))
|
||||
- ESLint config being generated when ESLint is not installed. ([#1198](https://github.com/wxt-dev/wxt/pull/1198))
|
||||
- Update `vite` dependency range to support v6 ([#1215](https://github.com/wxt-dev/wxt/pull/1215))
|
||||
- Automatically convert MV3 `content_security_policy` to MV2 ([#1168](https://github.com/wxt-dev/wxt/pull/1168))
|
||||
- Correctly remove child elements with integrated UI remove ([#1219](https://github.com/wxt-dev/wxt/pull/1219))
|
||||
- Make content script `matches` optional ([#1220](https://github.com/wxt-dev/wxt/pull/1220))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Fix analyze typo in type ([#1187](https://github.com/wxt-dev/wxt/pull/1187))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
- 1natsu ([@1natsu172](http://github.com/1natsu172))
|
||||
- Nishu ([@nishu-murmu](https://github.com/nishu-murmu))
|
||||
- Florian Metz ([@Timeraa](http://github.com/Timeraa))
|
||||
|
||||
## v0.19.16
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.15...wxt-v0.19.16)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- **hooks:** Add new `config:resolved` hook ([#1177](https://github.com/wxt-dev/wxt/pull/1177))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
|
||||
## v0.19.15
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.14...wxt-v0.19.15)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- Extract `wxt/storage` to its own package, `@wxt-dev/storage` ([#1129](https://github.com/wxt-dev/wxt/pull/1129))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Add "/" to `PublicPath` and `browser.runtime.getURL` ([#1171](https://github.com/wxt-dev/wxt/pull/1171))
|
||||
- Add extension ID to event used to invalidate `ContentScriptContext` ([#1175](https://github.com/wxt-dev/wxt/pull/1175))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
- Nishu ([@nishu-murmu](http://github.com/nishu-murmu))
|
||||
|
||||
## v0.19.14
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.13...wxt-v0.19.14)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- **storage:** Support storage items in batch functions ([#990](https://github.com/wxt-dev/wxt/pull/990))
|
||||
- Automatically disable 'Show warning about Self-XSS when pasing code' in new chrome ([#1159](https://github.com/wxt-dev/wxt/pull/1159))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Throw when config file does not exist ([#1156](https://github.com/wxt-dev/wxt/pull/1156))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Cleanup typos and broken links ([bb5ea34](https://github.com/wxt-dev/wxt/commit/bb5ea34))
|
||||
- Fix typo in `popup` and `options` EntrypointOptions ([#1121](https://github.com/wxt-dev/wxt/pull/1121))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- **deps:** Upgrade all non-major dependencies ([#1164](https://github.com/wxt-dev/wxt/pull/1164))
|
||||
- **deps:** Bump dev and non-breaking major dependencies ([#1167](https://github.com/wxt-dev/wxt/pull/1167))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
- Rxliuli ([@rxliuli](http://github.com/rxliuli))
|
||||
- Kongmoumou ([@kongmoumou](http://github.com/kongmoumou))
|
||||
- Florian Metz ([@Timeraa](http://github.com/Timeraa))
|
||||
- Bread Grocery <breadgrocery@gmail.com>
|
||||
|
||||
## v0.19.13
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.12...wxt-v0.19.13)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- **env:** Load env from `.env.[browser]` variants ([#1078](https://github.com/wxt-dev/wxt/pull/1078))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Don't use `#private` member variables in `ContentScriptContext` ([#1103](https://github.com/wxt-dev/wxt/pull/1103))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
- Craig Slusher ([@sleekslush](http://github.com/sleekslush))
|
||||
|
||||
## v0.19.12
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.11...wxt-v0.19.12)
|
||||
|
||||
@@ -8,6 +8,7 @@ exports[`Auto Imports > eslintrc > "enabled: 8" should output a JSON config file
|
||||
"ContentScriptContext": true,
|
||||
"InvalidMatchPattern": true,
|
||||
"MatchPattern": true,
|
||||
"MigrationError": true,
|
||||
"browser": true,
|
||||
"createIframeUi": true,
|
||||
"createIntegratedUi": true,
|
||||
@@ -34,6 +35,7 @@ const globals = {
|
||||
"ContentScriptContext": true,
|
||||
"InvalidMatchPattern": true,
|
||||
"MatchPattern": true,
|
||||
"MigrationError": true,
|
||||
"browser": true,
|
||||
"createIframeUi": true,
|
||||
"createIntegratedUi": true,
|
||||
@@ -68,6 +70,7 @@ exports[`Auto Imports > eslintrc > "enabled: true" should output a JSON config f
|
||||
"ContentScriptContext": true,
|
||||
"InvalidMatchPattern": true,
|
||||
"MatchPattern": true,
|
||||
"MigrationError": true,
|
||||
"browser": true,
|
||||
"createIframeUi": true,
|
||||
"createIntegratedUi": true,
|
||||
@@ -95,6 +98,7 @@ exports[`Auto Imports > eslintrc > should allow customizing the output 1`] = `
|
||||
"ContentScriptContext": "readonly",
|
||||
"InvalidMatchPattern": "readonly",
|
||||
"MatchPattern": "readonly",
|
||||
"MigrationError": "readonly",
|
||||
"browser": "readonly",
|
||||
"createIframeUi": "readonly",
|
||||
"createIntegratedUi": "readonly",
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('Auto Imports', () => {
|
||||
const ContentScriptContext: typeof import('wxt/client')['ContentScriptContext']
|
||||
const InvalidMatchPattern: typeof import('wxt/sandbox')['InvalidMatchPattern']
|
||||
const MatchPattern: typeof import('wxt/sandbox')['MatchPattern']
|
||||
const MigrationError: typeof import('wxt/storage')['MigrationError']
|
||||
const browser: typeof import('wxt/browser')['browser']
|
||||
const createIframeUi: typeof import('wxt/client')['createIframeUi']
|
||||
const createIntegratedUi: typeof import('wxt/client')['createIntegratedUi']
|
||||
@@ -151,6 +152,40 @@ describe('Auto Imports', () => {
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('"enabled: false" should NOT output an ESlint config file', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/popup.html', `<html></html>`);
|
||||
|
||||
await project.prepare({
|
||||
imports: {
|
||||
eslintrc: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(await project.fileExists('.wxt/eslint-auto-imports.mjs')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await project.fileExists('.wxt/eslintrc-auto-import.json')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT output an ESlint config file by default', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/popup.html', `<html></html>`);
|
||||
|
||||
await project.prepare();
|
||||
|
||||
expect(await project.fileExists('.wxt/eslint-auto-imports.mjs')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await project.fileExists('.wxt/eslintrc-auto-import.json')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow customizing the output', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile('entrypoints/popup.html', `<html></html>`);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TestProject } from '../utils';
|
||||
|
||||
describe('Dev Mode', () => {
|
||||
it('should not change ports when restarting the server', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'entrypoints/background.ts',
|
||||
'export default defineBackground(() => {})',
|
||||
);
|
||||
|
||||
const server = await project.startServer({
|
||||
runner: {
|
||||
disabled: true,
|
||||
},
|
||||
});
|
||||
const initialPort = server.port;
|
||||
await server.restart();
|
||||
const finalPort = server.port;
|
||||
await server.stop();
|
||||
|
||||
expect(finalPort).toBe(initialPort);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { WxtHooks } from '../../src/types';
|
||||
|
||||
const hooks: WxtHooks = {
|
||||
ready: vi.fn(),
|
||||
'config:resolved': vi.fn(),
|
||||
'prepare:types': vi.fn(),
|
||||
'prepare:publicPaths': vi.fn(),
|
||||
'build:before': vi.fn(),
|
||||
@@ -20,6 +21,9 @@ const hooks: WxtHooks = {
|
||||
'zip:sources:start': vi.fn(),
|
||||
'zip:sources:done': vi.fn(),
|
||||
'zip:done': vi.fn(),
|
||||
'server:created': vi.fn(),
|
||||
'server:started': vi.fn(),
|
||||
'server:closed': vi.fn(),
|
||||
};
|
||||
|
||||
function expectHooksToBeCalled(
|
||||
@@ -49,6 +53,7 @@ describe('Hooks', () => {
|
||||
|
||||
expectHooksToBeCalled({
|
||||
ready: true,
|
||||
'config:resolved': true,
|
||||
'prepare:types': true,
|
||||
'prepare:publicPaths': true,
|
||||
'build:before': false,
|
||||
@@ -65,6 +70,9 @@ describe('Hooks', () => {
|
||||
'zip:sources:start': false,
|
||||
'zip:sources:done': false,
|
||||
'zip:done': false,
|
||||
'server:created': false,
|
||||
'server:started': false,
|
||||
'server:closed': false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +84,7 @@ describe('Hooks', () => {
|
||||
|
||||
expectHooksToBeCalled({
|
||||
ready: true,
|
||||
'config:resolved': true,
|
||||
'prepare:types': true,
|
||||
'prepare:publicPaths': true,
|
||||
'build:before': true,
|
||||
@@ -92,6 +101,9 @@ describe('Hooks', () => {
|
||||
'zip:sources:start': false,
|
||||
'zip:sources:done': false,
|
||||
'zip:done': false,
|
||||
'server:created': false,
|
||||
'server:started': false,
|
||||
'server:closed': false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +115,7 @@ describe('Hooks', () => {
|
||||
|
||||
expectHooksToBeCalled({
|
||||
ready: true,
|
||||
'config:resolved': true,
|
||||
'prepare:types': true,
|
||||
'prepare:publicPaths': true,
|
||||
'build:before': true,
|
||||
@@ -119,6 +132,9 @@ describe('Hooks', () => {
|
||||
'zip:sources:start': false,
|
||||
'zip:sources:done': false,
|
||||
'zip:done': true,
|
||||
'server:created': false,
|
||||
'server:started': false,
|
||||
'server:closed': false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,6 +146,7 @@ describe('Hooks', () => {
|
||||
|
||||
expectHooksToBeCalled({
|
||||
ready: true,
|
||||
'config:resolved': true,
|
||||
'prepare:types': true,
|
||||
'prepare:publicPaths': true,
|
||||
'build:before': true,
|
||||
@@ -137,7 +154,7 @@ describe('Hooks', () => {
|
||||
'build:publicAssets': true,
|
||||
'build:manifestGenerated': true,
|
||||
'entrypoints:grouped': true,
|
||||
'entrypoints:resolved': true,
|
||||
'entrypoints:resolved': 2,
|
||||
'vite:build:extendConfig': 1,
|
||||
'vite:devServer:extendConfig': false,
|
||||
'zip:start': true,
|
||||
@@ -146,6 +163,9 @@ describe('Hooks', () => {
|
||||
'zip:sources:start': true,
|
||||
'zip:sources:done': true,
|
||||
'zip:done': true,
|
||||
'server:created': false,
|
||||
'server:started': false,
|
||||
'server:closed': false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,10 +179,12 @@ describe('Hooks', () => {
|
||||
disabled: true,
|
||||
},
|
||||
});
|
||||
expect(hooks['server:closed']).not.toBeCalled();
|
||||
await server.stop();
|
||||
|
||||
expectHooksToBeCalled({
|
||||
ready: true,
|
||||
'config:resolved': true,
|
||||
'prepare:types': true,
|
||||
'prepare:publicPaths': true,
|
||||
'build:before': true,
|
||||
@@ -179,6 +201,9 @@ describe('Hooks', () => {
|
||||
'zip:sources:start': false,
|
||||
'zip:sources:done': false,
|
||||
'zip:done': false,
|
||||
'server:created': 1,
|
||||
'server:started': 1,
|
||||
'server:closed': 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { normalizePath } from '../../src/core/utils/paths';
|
||||
|
||||
describe('Module Helpers', () => {
|
||||
describe('options', () => {
|
||||
it('should recieve the options defined in wxt.config.ts based on the configKey field', async () => {
|
||||
it('should receive the options defined in wxt.config.ts based on the configKey field', async () => {
|
||||
const options = { key: '123' };
|
||||
const reportOptions = vi.fn();
|
||||
vi.stubGlobal('reportOptions', reportOptions);
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('TypeScript Project', () => {
|
||||
|
||||
declare module "wxt/browser" {
|
||||
export type PublicPath =
|
||||
| "/"
|
||||
| "/options.html"
|
||||
| "/popup.html"
|
||||
| "/sandbox.html"
|
||||
|
||||
@@ -157,4 +157,18 @@ describe('User Config', () => {
|
||||
await project.fileExists('.output/test-chrome-mv3-development-dev-build'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error when config file not exist', async () => {
|
||||
const project = new TestProject();
|
||||
project.addFile(
|
||||
'src/entrypoints/background.ts',
|
||||
`export default defineBackground(
|
||||
() => console.log('Hello background'),
|
||||
);`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
project.build({ configFile: 'foo.config.ts' }),
|
||||
).rejects.toThrowError(/not found/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,6 +181,42 @@ describe('Zipping', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should exclude skipped entrypoints from respective browser sources zip', async () => {
|
||||
const project = new TestProject({
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
});
|
||||
project.addFile(
|
||||
'entrypoints/not-firefox.content.ts',
|
||||
`export default defineContentScript({
|
||||
matches: ['*://*/*'],
|
||||
exclude: ['firefox'],
|
||||
main() {},
|
||||
});`,
|
||||
);
|
||||
project.addFile(
|
||||
'entrypoints/all.content.ts',
|
||||
`export default defineContentScript({
|
||||
matches: ['*://*/*'],
|
||||
main(ctx) {},
|
||||
});
|
||||
`,
|
||||
);
|
||||
const unzipDir = project.resolvePath('.output/test-1.0.0-sources');
|
||||
const sourcesZip = project.resolvePath('.output/test-1.0.0-sources.zip');
|
||||
|
||||
await project.zip({
|
||||
browser: 'firefox',
|
||||
});
|
||||
await extract(sourcesZip, { dir: unzipDir });
|
||||
expect(
|
||||
await project.fileExists(unzipDir, 'entrypoints/not-firefox.content.ts'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await project.fileExists(unzipDir, 'entrypoints/all.content.ts'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['firefox', 'opera'])(
|
||||
'should create sources zip for "%s" browser when sourcesZip is undefined',
|
||||
async (browser) => {
|
||||
|
||||
+18
-18
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wxt",
|
||||
"type": "module",
|
||||
"version": "0.19.12",
|
||||
"version": "0.19.19",
|
||||
"description": "Next gen framework for developing web extensions",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -82,25 +82,25 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aklinker1/rollup-plugin-visualizer": "5.12.0",
|
||||
"@types/chrome": "^0.0.269",
|
||||
"@types/webextension-polyfill": "^0.10.7",
|
||||
"@types/chrome": "^0.0.280",
|
||||
"@types/webextension-polyfill": "^0.12.1",
|
||||
"@webext-core/fake-browser": "^1.3.1",
|
||||
"@webext-core/isolated-element": "^1.1.2",
|
||||
"@webext-core/match-patterns": "^1.0.3",
|
||||
"@wxt-dev/storage": "workspace:^1.0.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"c12": "^1.11.2",
|
||||
"cac": "^6.7.14",
|
||||
"chokidar": "^3.6.0",
|
||||
"ci-info": "^4.0.0",
|
||||
"ci-info": "^4.1.0",
|
||||
"consola": "^3.2.3",
|
||||
"defu": "^6.1.4",
|
||||
"dequal": "^2.0.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"esbuild": "^0.21.5",
|
||||
"fast-glob": "^3.3.2",
|
||||
"filesize": "^10.1.6",
|
||||
"fs-extra": "^11.2.0",
|
||||
"get-port": "^7.1.0",
|
||||
"get-port-please": "^3.1.2",
|
||||
"giget": "^1.2.3",
|
||||
"hookable": "^5.5.3",
|
||||
"is-wsl": "^3.1.0",
|
||||
@@ -110,40 +110,40 @@
|
||||
"linkedom": "^0.18.5",
|
||||
"magicast": "^0.3.5",
|
||||
"minimatch": "^10.0.1",
|
||||
"nano-spawn": "^0.1.0",
|
||||
"nano-spawn": "^0.2.0",
|
||||
"normalize-path": "^3.0.0",
|
||||
"nypm": "^0.3.12",
|
||||
"ohash": "^1.1.4",
|
||||
"open": "^10.1.0",
|
||||
"ora": "^8.1.0",
|
||||
"ora": "^8.1.1",
|
||||
"perfect-debounce": "^1.0.0",
|
||||
"picocolors": "^1.1.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"prompts": "^2.4.2",
|
||||
"publish-browser-extension": "^2.2.2",
|
||||
"scule": "^1.3.0",
|
||||
"unimport": "^3.13.1",
|
||||
"vite": "^5.4.8",
|
||||
"vite-node": "^2.1.2",
|
||||
"vite": "^5.0.0 || ^6.0.0",
|
||||
"vite-node": "^2.1.4",
|
||||
"web-ext-run": "^0.2.1",
|
||||
"webextension-polyfill": "^0.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aklinker1/check": "^1.4.5",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@faker-js/faker": "^9.2.0",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/lodash.merge": "^4.6.9",
|
||||
"@types/node": "^20.16.10",
|
||||
"@types/node": "^20.17.6",
|
||||
"@types/normalize-path": "^3.0.2",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"extract-zip": "^2.0.1",
|
||||
"happy-dom": "^14.12.3",
|
||||
"happy-dom": "^15.11.4",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"oxlint": "^0.9.9",
|
||||
"publint": "^0.2.11",
|
||||
"oxlint": "^0.11.1",
|
||||
"publint": "^0.2.12",
|
||||
"tsx": "4.15.7",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript": "^5.6.3",
|
||||
"unbuild": "^2.0.0",
|
||||
"vitest": "^2.1.2",
|
||||
"vitest": "^2.1.4",
|
||||
"vitest-plugin-random-seed": "^1.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('Module Utilities', () => {
|
||||
|
||||
wxt.config.vite = () => Promise.resolve(userConfig);
|
||||
addViteConfig(wxt, () => moduleConfig);
|
||||
await wxt.hooks.callHook('ready', wxt);
|
||||
await wxt.hooks.callHook('config:resolved', wxt);
|
||||
const actual = await wxt.config.vite(wxt.config.env);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
@@ -31,7 +31,7 @@ describe('Module Utilities', () => {
|
||||
|
||||
wxt.config.vite = () => userConfig;
|
||||
addViteConfig(wxt, () => moduleConfig);
|
||||
await wxt.hooks.callHook('ready', wxt);
|
||||
await wxt.hooks.callHook('config:resolved', wxt);
|
||||
const actual = await wxt.config.vite(wxt.config.env);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
@@ -44,7 +44,7 @@ describe('Module Utilities', () => {
|
||||
const wxt = fakeWxt({ hooks: createHooks() });
|
||||
|
||||
addImportPreset(wxt, preset);
|
||||
await wxt.hooks.callHook('ready', wxt);
|
||||
await wxt.hooks.callHook('config:resolved', wxt);
|
||||
|
||||
expect(wxt.config.imports && wxt.config.imports.presets).toContain(
|
||||
preset,
|
||||
@@ -63,7 +63,7 @@ describe('Module Utilities', () => {
|
||||
});
|
||||
|
||||
addImportPreset(wxt, preset);
|
||||
await wxt.hooks.callHook('ready', wxt);
|
||||
await wxt.hooks.callHook('config:resolved', wxt);
|
||||
|
||||
expect(wxt.config.imports && wxt.config.imports.presets).toHaveLength(2);
|
||||
});
|
||||
@@ -78,7 +78,7 @@ describe('Module Utilities', () => {
|
||||
});
|
||||
|
||||
addImportPreset(wxt, preset);
|
||||
await wxt.hooks.callHook('ready', wxt);
|
||||
await wxt.hooks.callHook('config:resolved', wxt);
|
||||
|
||||
expect(wxt.config.imports).toBe(false);
|
||||
});
|
||||
|
||||
@@ -80,7 +80,6 @@ export type {
|
||||
Theme,
|
||||
TopSites,
|
||||
Types,
|
||||
Urlbar,
|
||||
UserScripts,
|
||||
WebNavigation,
|
||||
WebRequest,
|
||||
|
||||
@@ -18,7 +18,7 @@ export default defineWxtModule({
|
||||
let unimport: Unimport;
|
||||
|
||||
// Add user module imports to config
|
||||
wxt.hooks.hook('ready', () => {
|
||||
wxt.hooks.hook('config:resolved', () => {
|
||||
const addModuleImports = (module: WxtModule<any>) => {
|
||||
if (!module.imports) return;
|
||||
|
||||
@@ -30,10 +30,10 @@ export default defineWxtModule({
|
||||
wxt.config.userModules.forEach(addModuleImports);
|
||||
});
|
||||
|
||||
// Create unimport instance AFTER "ready" so any modifications to the
|
||||
// config inside "ready" are applied.
|
||||
// Create unimport instance AFTER "config:resolved" so any modifications to the
|
||||
// config inside "config:resolved" are applied.
|
||||
wxt.hooks.afterEach((event) => {
|
||||
if (event.name === 'ready') {
|
||||
if (event.name === 'config:resolved') {
|
||||
unimport = createUnimport(options);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ContentScriptContext } from '..';
|
||||
import { fakeBrowser } from '@webext-core/fake-browser';
|
||||
import { sleep } from '../../../core/utils/time';
|
||||
|
||||
/**
|
||||
* When dispatching events on document/window/etc, they are fired on the next
|
||||
* tick of the event loop. So waiting a timeout of 0 will ensure they've been
|
||||
* fired.
|
||||
*/
|
||||
function waitForEventsToFire() {
|
||||
return new Promise((res) => setTimeout(res));
|
||||
}
|
||||
|
||||
describe('Content Script Context', () => {
|
||||
beforeEach(() => {
|
||||
@@ -31,11 +39,11 @@ describe('Content Script Context', () => {
|
||||
ctx.onInvalidated(onInvalidated);
|
||||
|
||||
// Wait for events to run before next tick next tick
|
||||
await sleep(0);
|
||||
await waitForEventsToFire();
|
||||
|
||||
// Create a new context after first is initialized, and wait for it to initialize
|
||||
new ContentScriptContext(name);
|
||||
await sleep(0);
|
||||
await waitForEventsToFire();
|
||||
|
||||
expect(onInvalidated).toBeCalled();
|
||||
expect(ctx.isValid).toBe(false);
|
||||
@@ -47,13 +55,35 @@ describe('Content Script Context', () => {
|
||||
ctx.onInvalidated(onInvalidated);
|
||||
|
||||
// Wait for events to run before next tick next tick
|
||||
await sleep(0);
|
||||
await waitForEventsToFire();
|
||||
|
||||
// Create a new context after first is initialized, and wait for it to initialize
|
||||
new ContentScriptContext('test2');
|
||||
await sleep(0);
|
||||
await waitForEventsToFire();
|
||||
|
||||
expect(onInvalidated).not.toBeCalled();
|
||||
expect(ctx.isValid).toBe(true);
|
||||
});
|
||||
|
||||
describe('addEventListener', () => {
|
||||
const context = new ContentScriptContext('test');
|
||||
it('should infer types correctly for the window target', () => {
|
||||
context.addEventListener(window, 'DOMContentLoaded', (_) => {});
|
||||
context.addEventListener(window, 'orientationchange', (_) => {});
|
||||
context.addEventListener(window, 'wxt:locationchange', (_) => {});
|
||||
// @ts-expect-error
|
||||
context.addEventListener(window, 'visibilitychange', (_) => {});
|
||||
});
|
||||
|
||||
it('should infer types correctly for the document target', () => {
|
||||
context.addEventListener(document, 'visibilitychange', (_) => {});
|
||||
context.addEventListener(document, 'readystatechange', (_) => {});
|
||||
});
|
||||
|
||||
it('should infer types correctly for HTML element targets', () => {
|
||||
const button = document.createElement('button');
|
||||
context.addEventListener(button, 'click', (_) => {});
|
||||
context.addEventListener(button, 'mouseover', (_) => {});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,9 @@ import { createLocationWatcher } from './location-watcher';
|
||||
* ```
|
||||
*/
|
||||
export class ContentScriptContext implements AbortController {
|
||||
private static SCRIPT_STARTED_MESSAGE_TYPE = 'wxt:content-script-started';
|
||||
private static SCRIPT_STARTED_MESSAGE_TYPE = getUniqueEventName(
|
||||
'wxt:content-script-started',
|
||||
);
|
||||
|
||||
private isTopFrame = window.self === window.top;
|
||||
private abortController: AbortController;
|
||||
@@ -173,19 +175,32 @@ export class ContentScriptContext implements AbortController {
|
||||
* ctx.addEventListener(document, "visibilitychange", () => {
|
||||
* // ...
|
||||
* });
|
||||
* ctx.addEventListener(document, "wxt:locationchange", () => {
|
||||
* ctx.addEventListener(window, "wxt:locationchange", () => {
|
||||
* // ...
|
||||
* });
|
||||
*/
|
||||
addEventListener<
|
||||
TTarget extends EventTarget,
|
||||
TType extends keyof WxtContentScriptEventMap,
|
||||
>(
|
||||
target: TTarget,
|
||||
addEventListener<TType extends keyof WxtWindowEventMap>(
|
||||
target: Window,
|
||||
type: TType,
|
||||
handler: (event: WxtContentScriptEventMap[TType]) => void,
|
||||
handler: (event: WxtWindowEventMap[TType]) => void,
|
||||
options?: AddEventListenerOptions,
|
||||
) {
|
||||
): void;
|
||||
addEventListener<TType extends keyof DocumentEventMap>(
|
||||
target: Document,
|
||||
type: keyof DocumentEventMap,
|
||||
handler: (event: DocumentEventMap[TType]) => void,
|
||||
options?: AddEventListenerOptions,
|
||||
): void;
|
||||
addEventListener<TTarget extends EventTarget>(
|
||||
target: TTarget,
|
||||
...params: Parameters<TTarget['addEventListener']>
|
||||
): void;
|
||||
addEventListener(
|
||||
target: EventTarget,
|
||||
type: string,
|
||||
handler: (event: Event) => void,
|
||||
options?: AddEventListenerOptions,
|
||||
): void {
|
||||
if (type === 'wxt:locationchange') {
|
||||
// Start the location watcher when adding the event for the first time
|
||||
if (this.isValid) this.locationWatcher.run();
|
||||
@@ -193,7 +208,6 @@ export class ContentScriptContext implements AbortController {
|
||||
|
||||
target.addEventListener?.(
|
||||
type.startsWith('wxt:') ? getUniqueEventName(type) : type,
|
||||
// @ts-expect-error: Event don't match, but that's OK, EventTarget doesn't allow custom types in the callback
|
||||
handler,
|
||||
{
|
||||
...options,
|
||||
@@ -245,6 +259,6 @@ export class ContentScriptContext implements AbortController {
|
||||
}
|
||||
}
|
||||
|
||||
interface WxtContentScriptEventMap extends WindowEventMap {
|
||||
export interface WxtWindowEventMap extends WindowEventMap {
|
||||
'wxt:locationchange': WxtLocationChangeEvent;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export function createIntegratedUi<TMounted>(
|
||||
};
|
||||
const remove = () => {
|
||||
options.onRemove?.(mounted);
|
||||
wrapper.replaceChildren();
|
||||
wrapper.remove();
|
||||
mounted = undefined;
|
||||
};
|
||||
@@ -96,7 +97,7 @@ export function createIframeUi<TMounted>(
|
||||
*
|
||||
* > This function is async because it has to load the CSS via a network call.
|
||||
*
|
||||
* @see https://wxt.dev/guide/essentials/content-scripts.html#shadowroot
|
||||
* @see https://wxt.dev/guide/essentials/content-scripts.html#shadow-root
|
||||
*/
|
||||
export async function createShadowRootUi<TMounted>(
|
||||
ctx: ContentScriptContext,
|
||||
|
||||
@@ -28,7 +28,7 @@ import { installSourcemapsSupport } from 'vite-node/source-map';
|
||||
export async function createViteBuilder(
|
||||
wxtConfig: ResolvedConfig,
|
||||
hooks: Hookable<WxtHooks>,
|
||||
server?: WxtDevServer,
|
||||
getWxtDevServer?: () => WxtDevServer | undefined,
|
||||
): Promise<WxtBuilder> {
|
||||
const vite = await import('vite');
|
||||
|
||||
@@ -65,6 +65,8 @@ export async function createViteBuilder(
|
||||
ignored: [`${wxtConfig.outBaseDir}/**`, `${wxtConfig.wxtDir}/**`],
|
||||
};
|
||||
|
||||
const server = getWxtDevServer?.();
|
||||
|
||||
config.plugins ??= [];
|
||||
config.plugins.push(
|
||||
wxtPlugins.download(wxtConfig),
|
||||
@@ -193,7 +195,7 @@ export async function createViteBuilder(
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the basic config for building a sinlge CSS entrypoint in [multi-page mode](https://vitejs.dev/guide/build.html#multi-page-app).
|
||||
* Return the basic config for building a single CSS entrypoint in [multi-page mode](https://vitejs.dev/guide/build.html#multi-page-app).
|
||||
*/
|
||||
const getCssConfig = (entrypoint: Entrypoint): vite.InlineConfig => {
|
||||
return {
|
||||
|
||||
@@ -21,12 +21,13 @@ import { createExtensionRunner } from './runners';
|
||||
import { Mutex } from 'async-mutex';
|
||||
import pc from 'picocolors';
|
||||
import { relative } from 'node:path';
|
||||
import { registerWxt, wxt } from './wxt';
|
||||
import { deinitWxtModules, initWxtModules, registerWxt, wxt } from './wxt';
|
||||
import { unnormalizePath } from './utils/paths';
|
||||
import {
|
||||
getContentScriptJs,
|
||||
mapWxtOptionsToRegisteredContentScript,
|
||||
} from './utils/content-scripts';
|
||||
import { createKeyboardShortcuts } from './keyboard-shortcuts';
|
||||
|
||||
/**
|
||||
* Creates a dev server and pre-builds all the files that need to exist before loading the extension.
|
||||
@@ -40,64 +41,117 @@ import {
|
||||
export async function createServer(
|
||||
inlineConfig?: InlineConfig,
|
||||
): Promise<WxtDevServer> {
|
||||
await registerWxt('serve', inlineConfig, async (config) => {
|
||||
const { port, hostname } = config.dev.server!;
|
||||
const serverInfo: ServerInfo = {
|
||||
await registerWxt('serve', inlineConfig);
|
||||
|
||||
wxt.server = await createServerInternal();
|
||||
await wxt.hooks.callHook('server:created', wxt, wxt.server);
|
||||
return wxt.server;
|
||||
}
|
||||
|
||||
async function createServerInternal(): Promise<WxtDevServer> {
|
||||
const getServerInfo = (): ServerInfo => {
|
||||
const { port, hostname } = wxt.config.dev.server!;
|
||||
return {
|
||||
port,
|
||||
hostname,
|
||||
origin: `http://${hostname}:${port}`,
|
||||
};
|
||||
};
|
||||
|
||||
// Server instance must be created first so its reference can be added to the internal config used
|
||||
// to pre-render entrypoints
|
||||
const server: WxtDevServer = {
|
||||
...serverInfo,
|
||||
get watcher() {
|
||||
return builderServer.watcher;
|
||||
},
|
||||
get ws() {
|
||||
return builderServer.ws;
|
||||
},
|
||||
currentOutput: undefined,
|
||||
async start() {
|
||||
await builderServer.listen();
|
||||
wxt.logger.success(`Started dev server @ ${serverInfo.origin}`);
|
||||
await buildAndOpenBrowser();
|
||||
},
|
||||
async stop() {
|
||||
await runner.closeBrowser();
|
||||
await builderServer.close();
|
||||
},
|
||||
async restart() {
|
||||
await closeAndRecreateRunner();
|
||||
await buildAndOpenBrowser();
|
||||
},
|
||||
transformHtml(url, html, originalUrl) {
|
||||
return builderServer.transformHtml(url, html, originalUrl);
|
||||
},
|
||||
reloadContentScript(payload) {
|
||||
server.ws.send('wxt:reload-content-script', payload);
|
||||
},
|
||||
reloadPage(path) {
|
||||
server.ws.send('wxt:reload-page', path);
|
||||
},
|
||||
reloadExtension() {
|
||||
server.ws.send('wxt:reload-extension');
|
||||
},
|
||||
async restartBrowser() {
|
||||
await closeAndRecreateRunner();
|
||||
await runner.openBrowser();
|
||||
},
|
||||
};
|
||||
return server;
|
||||
});
|
||||
|
||||
const server = wxt.server!;
|
||||
let [runner, builderServer] = await Promise.all([
|
||||
createExtensionRunner(),
|
||||
wxt.builder.createServer(server),
|
||||
wxt.builder.createServer(getServerInfo()),
|
||||
]);
|
||||
|
||||
// Used to track if modules need to be re-initialized
|
||||
let wasStopped = false;
|
||||
|
||||
// Server instance must be created first so its reference can be added to the internal config used
|
||||
// to pre-render entrypoints
|
||||
const server: WxtDevServer = {
|
||||
get hostname() {
|
||||
return getServerInfo().hostname;
|
||||
},
|
||||
get port() {
|
||||
return getServerInfo().port;
|
||||
},
|
||||
get origin() {
|
||||
return getServerInfo().origin;
|
||||
},
|
||||
get watcher() {
|
||||
return builderServer.watcher;
|
||||
},
|
||||
get ws() {
|
||||
return builderServer.ws;
|
||||
},
|
||||
currentOutput: undefined,
|
||||
async start() {
|
||||
if (wasStopped) {
|
||||
await wxt.reloadConfig();
|
||||
runner = await createExtensionRunner();
|
||||
builderServer = await wxt.builder.createServer(getServerInfo());
|
||||
await initWxtModules();
|
||||
}
|
||||
|
||||
await builderServer.listen();
|
||||
wxt.logger.success(`Started dev server @ ${server.origin}`);
|
||||
await wxt.hooks.callHook('server:started', wxt, server);
|
||||
|
||||
// Register content scripts for the first time after the background starts
|
||||
// up since they're not listed in the manifest.
|
||||
// Add listener before opening the browser to guarentee it is present when
|
||||
// the extension sends back the initialization message.
|
||||
server.ws.on('wxt:background-initialized', () => {
|
||||
if (server.currentOutput == null) return;
|
||||
reloadContentScripts(server.currentOutput.steps, server);
|
||||
});
|
||||
|
||||
await buildAndOpenBrowser();
|
||||
|
||||
// Listen for file changes and reload different parts of the extension accordingly
|
||||
const reloadOnChange = createFileReloader(server);
|
||||
server.watcher.on('all', reloadOnChange);
|
||||
keyboardShortcuts.start();
|
||||
keyboardShortcuts.printHelp();
|
||||
},
|
||||
|
||||
async stop() {
|
||||
wasStopped = true;
|
||||
keyboardShortcuts.stop();
|
||||
await runner.closeBrowser();
|
||||
await builderServer.close();
|
||||
await wxt.hooks.callHook('server:closed', wxt, server);
|
||||
|
||||
deinitWxtModules();
|
||||
server.currentOutput = undefined;
|
||||
},
|
||||
async restart() {
|
||||
await server.stop();
|
||||
await server.start();
|
||||
},
|
||||
transformHtml(url, html, originalUrl) {
|
||||
return builderServer.transformHtml(url, html, originalUrl);
|
||||
},
|
||||
reloadContentScript(payload) {
|
||||
server.ws.send('wxt:reload-content-script', payload);
|
||||
},
|
||||
reloadPage(path) {
|
||||
server.ws.send('wxt:reload-page', path);
|
||||
},
|
||||
reloadExtension() {
|
||||
server.ws.send('wxt:reload-extension');
|
||||
},
|
||||
async restartBrowser() {
|
||||
await runner.closeBrowser();
|
||||
keyboardShortcuts.stop();
|
||||
await wxt.reloadConfig();
|
||||
runner = await createExtensionRunner();
|
||||
await runner.openBrowser();
|
||||
keyboardShortcuts.start();
|
||||
},
|
||||
};
|
||||
const keyboardShortcuts = createKeyboardShortcuts(server);
|
||||
|
||||
const buildAndOpenBrowser = async () => {
|
||||
// Build after starting the dev server so it can be used to transform HTML files
|
||||
server.currentOutput = await internalBuild();
|
||||
@@ -114,26 +168,6 @@ export async function createServer(
|
||||
await runner.openBrowser();
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops the previous runner, grabs the latest config, and recreates the runner.
|
||||
*/
|
||||
const closeAndRecreateRunner = async () => {
|
||||
await runner.closeBrowser();
|
||||
await wxt.reloadConfig();
|
||||
runner = await createExtensionRunner();
|
||||
};
|
||||
|
||||
// Register content scripts for the first time after the background starts up since they're not
|
||||
// listed in the manifest
|
||||
server.ws.on('wxt:background-initialized', () => {
|
||||
if (server.currentOutput == null) return;
|
||||
reloadContentScripts(server.currentOutput.steps, server);
|
||||
});
|
||||
|
||||
// Listen for file changes and reload different parts of the extension accordingly
|
||||
const reloadOnChange = createFileReloader(server);
|
||||
server.watcher.on('all', reloadOnChange);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
@@ -206,6 +240,7 @@ function createFileReloader(server: WxtDevServer) {
|
||||
break;
|
||||
case 'content-script-reload':
|
||||
reloadContentScripts(changes.changedSteps, server);
|
||||
|
||||
const rebuiltNames = changes.rebuildGroups
|
||||
.flat()
|
||||
.map((entry) => entry.name);
|
||||
|
||||
@@ -78,6 +78,7 @@ async function getPathsDeclarationEntry(
|
||||
isHtmlEntrypoint(entry) ? '.html' : '.js',
|
||||
),
|
||||
)
|
||||
.concat([''])
|
||||
.concat(await getPublicFiles());
|
||||
|
||||
await wxt.hooks.callHook('prepare:publicPaths', wxt, paths);
|
||||
@@ -176,7 +177,7 @@ declare module "wxt/browser" {
|
||||
message.message,
|
||||
),
|
||||
),
|
||||
// Include a final union-based override so TS accepts valid string templates or concatinations
|
||||
// Include a final union-based override so TS accepts valid string templates or concatenations
|
||||
// ie: browser.i18n.getMessage(`some_enum_${enumValue}`)
|
||||
renderGetMessageOverload(
|
||||
messages.map((message) => `"${message.name}"`).join(' | '),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import readline from 'node:readline';
|
||||
import { WxtDevServer } from '../types';
|
||||
import { wxt } from './wxt';
|
||||
import pc from 'picocolors';
|
||||
|
||||
export interface KeyboardShortcutWatcher {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
printHelp(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function that creates a keyboard shortcut handler for the extension.
|
||||
*/
|
||||
export function createKeyboardShortcuts(
|
||||
server: WxtDevServer,
|
||||
): KeyboardShortcutWatcher {
|
||||
let isWatching = false;
|
||||
let rl: readline.Interface | undefined;
|
||||
|
||||
const handleInput = (line: string) => {
|
||||
// Only handle our specific command
|
||||
if (line.trim() === 'o') {
|
||||
server.restartBrowser();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (isWatching) return;
|
||||
|
||||
rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
terminal: false, // Don't intercept ctrl+C, ctrl+Z, etc
|
||||
});
|
||||
|
||||
rl.on('line', handleInput);
|
||||
isWatching = true;
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (!isWatching) return;
|
||||
|
||||
if (rl) {
|
||||
rl.close();
|
||||
rl = undefined;
|
||||
}
|
||||
|
||||
isWatching = false;
|
||||
},
|
||||
|
||||
printHelp() {
|
||||
if (!wxt.config.runnerConfig.config.disabled) {
|
||||
wxt.logger.info(
|
||||
`${pc.dim('Press')} ${pc.bold('o + enter')} ${pc.dim('to reopen the browser')}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { builtinModules } from '../builtin-modules';
|
||||
import { getEslintVersion } from './utils/eslint';
|
||||
import { safeStringToNumber } from './utils/number';
|
||||
import { loadEnv } from './utils/env';
|
||||
import { getPort } from 'get-port-please';
|
||||
|
||||
/**
|
||||
* Given an inline config, discover the config file if necessary, merge the results, resolve any
|
||||
@@ -53,6 +54,9 @@ export async function resolveConfig(
|
||||
esmResolve: true,
|
||||
},
|
||||
});
|
||||
if (inlineConfig.configFile && metadata.layers?.length === 0) {
|
||||
throw Error(`Config file "${inlineConfig.configFile}" not found`);
|
||||
}
|
||||
userConfig = loadedConfig ?? {};
|
||||
userConfigMetadata = metadata;
|
||||
}
|
||||
@@ -74,7 +78,7 @@ export async function resolveConfig(
|
||||
const mode = mergedConfig.mode ?? COMMAND_MODES[command];
|
||||
const env: ConfigEnv = { browser, command, manifestVersion, mode };
|
||||
|
||||
loadEnv(mode); // Load any environment variables used below
|
||||
loadEnv(mode, browser); // Load any environment variables used below
|
||||
|
||||
const root = path.resolve(
|
||||
inlineConfig.root ?? userConfig.root ?? process.cwd(),
|
||||
@@ -134,14 +138,19 @@ export async function resolveConfig(
|
||||
|
||||
let devServerConfig: ResolvedConfig['dev']['server'];
|
||||
if (command === 'serve') {
|
||||
const hostname = mergedConfig.dev?.server?.hostname ?? 'localhost';
|
||||
let port = mergedConfig.dev?.server?.port;
|
||||
if (port == null || !isFinite(port)) {
|
||||
const { default: getPort, portNumbers } = await import('get-port');
|
||||
port = await getPort({ port: portNumbers(3000, 3010) });
|
||||
port = await getPort({
|
||||
port: 3000,
|
||||
portRange: [3001, 3010],
|
||||
// Passing host required for Mac, unsure of Windows/Linux
|
||||
host: hostname,
|
||||
});
|
||||
}
|
||||
devServerConfig = {
|
||||
port,
|
||||
hostname: mergedConfig.dev?.server?.hostname ?? 'localhost',
|
||||
hostname,
|
||||
watchDebounce: safeStringToNumber(process.env.WXT_WATCH_DEBOUNCE) ?? 800,
|
||||
};
|
||||
}
|
||||
@@ -383,10 +392,11 @@ async function getUnimportEslintOptions(
|
||||
case 'auto':
|
||||
const version = await getEslintVersion();
|
||||
let major = parseInt(version[0]);
|
||||
if (isNaN(major)) eslintEnabled = false;
|
||||
if (major <= 8) eslintEnabled = 8;
|
||||
else if (major >= 9) eslintEnabled = 9;
|
||||
// NaN
|
||||
else eslintEnabled = 8;
|
||||
else eslintEnabled = false;
|
||||
break;
|
||||
case true:
|
||||
eslintEnabled = 8;
|
||||
|
||||
@@ -50,7 +50,10 @@ export function createWebExtRunner(): ExtensionRunner {
|
||||
wxtUserConfig?.chromiumPref,
|
||||
DEFAULT_CHROMIUM_PREFS,
|
||||
),
|
||||
args: wxtUserConfig?.chromiumArgs,
|
||||
args: [
|
||||
'--unsafely-disable-devtools-self-xss-warnings',
|
||||
...(wxtUserConfig?.chromiumArgs ?? []),
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -1581,6 +1581,41 @@ describe('Manifest Utils', () => {
|
||||
permissions: ['tabs', 'scripting'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert MV3 CSP object to MV2 CSP string with localhost for MV2', async () => {
|
||||
const entrypoints: Entrypoint[] = [];
|
||||
const buildOutput = fakeBuildOutput();
|
||||
const inputCsp =
|
||||
"script-src 'self' 'wasm-unsafe-eval'; object-src 'self';";
|
||||
const expectedCsp =
|
||||
"script-src 'self' 'wasm-unsafe-eval' http://localhost:3000; object-src 'self';";
|
||||
|
||||
// Setup WXT for Firefox and serve command
|
||||
setFakeWxt({
|
||||
config: {
|
||||
browser: 'firefox',
|
||||
command: 'serve',
|
||||
manifestVersion: 2,
|
||||
manifest: {
|
||||
content_security_policy: {
|
||||
extension_pages: inputCsp,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: fakeWxtDevServer({
|
||||
port: 3000,
|
||||
hostname: 'localhost',
|
||||
origin: 'http://localhost:3000',
|
||||
}),
|
||||
});
|
||||
|
||||
const { manifest: actual } = await generateManifest(
|
||||
entrypoints,
|
||||
buildOutput,
|
||||
);
|
||||
|
||||
expect(actual.content_security_policy).toEqual(expectedCsp);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -71,6 +71,33 @@ describe('Detect Dev Changes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('modules/*', () => {
|
||||
it("should return 'full-restart' when one of the changed files is in the WXT modules folder", () => {
|
||||
const modulesDir = '/root/modules';
|
||||
setFakeWxt({
|
||||
config: {
|
||||
modulesDir,
|
||||
},
|
||||
});
|
||||
const changes = [
|
||||
'/root/src/public/image.svg',
|
||||
`${modulesDir}/example.ts`,
|
||||
];
|
||||
const currentOutput: BuildOutput = {
|
||||
manifest: fakeManifest(),
|
||||
publicAssets: [],
|
||||
steps: [],
|
||||
};
|
||||
const expected: DevModeChange = {
|
||||
type: 'full-restart',
|
||||
};
|
||||
|
||||
const actual = detectDevChanges(changes, currentOutput);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('web-ext.config.ts', () => {
|
||||
it("should return 'browser-restart' when one of the changed files is the config file", () => {
|
||||
const runnerFile = '/root/web-ext.config.ts';
|
||||
|
||||
@@ -701,7 +701,7 @@ describe('findEntrypoints', () => {
|
||||
});
|
||||
|
||||
describe('include option', () => {
|
||||
it("should filter out the background when include doesn't contain the target browser", async () => {
|
||||
it("should mark the background as skipped when include doesn't contain the target browser", async () => {
|
||||
globMock.mockResolvedValueOnce(['background.ts']);
|
||||
importEntrypointMock.mockResolvedValue({
|
||||
include: ['not' + config.browser],
|
||||
@@ -709,10 +709,15 @@ describe('findEntrypoints', () => {
|
||||
|
||||
const entrypoints = await findEntrypoints();
|
||||
|
||||
expect(entrypoints).toEqual([]);
|
||||
expect(entrypoints).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'background',
|
||||
skipped: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should filter out content scripts when include doesn't contain the target browser", async () => {
|
||||
it("should mark content scripts as skipped when include doesn't contain the target browser", async () => {
|
||||
globMock.mockResolvedValueOnce(['example.content.ts']);
|
||||
importEntrypointMock.mockResolvedValue({
|
||||
include: ['not' + config.browser],
|
||||
@@ -720,10 +725,15 @@ describe('findEntrypoints', () => {
|
||||
|
||||
const entrypoints = await findEntrypoints();
|
||||
|
||||
expect(entrypoints).toEqual([]);
|
||||
expect(entrypoints).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'example',
|
||||
skipped: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should filter out the popup when include doesn't contain the target browser", async () => {
|
||||
it("should mark the popup as skipped when include doesn't contain the target browser", async () => {
|
||||
globMock.mockResolvedValueOnce(['popup.html']);
|
||||
readFileMock.mockResolvedValueOnce(
|
||||
`<html>
|
||||
@@ -737,10 +747,15 @@ describe('findEntrypoints', () => {
|
||||
|
||||
const entrypoints = await findEntrypoints();
|
||||
|
||||
expect(entrypoints).toEqual([]);
|
||||
expect(entrypoints).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'popup',
|
||||
skipped: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should filter out the options page when include doesn't contain the target browser", async () => {
|
||||
it("should mark the options page as skipped when include doesn't contain the target browser", async () => {
|
||||
globMock.mockResolvedValueOnce(['options.html']);
|
||||
readFileMock.mockResolvedValueOnce(
|
||||
`<html>
|
||||
@@ -754,10 +769,15 @@ describe('findEntrypoints', () => {
|
||||
|
||||
const entrypoints = await findEntrypoints();
|
||||
|
||||
expect(entrypoints).toEqual([]);
|
||||
expect(entrypoints).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'options',
|
||||
skipped: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should filter out an unlisted page when include doesn't contain the target browser", async () => {
|
||||
it("should mark unlisted pages as skipped when include doesn't contain the target browser", async () => {
|
||||
globMock.mockResolvedValueOnce(['unlisted.html']);
|
||||
readFileMock.mockResolvedValueOnce(
|
||||
`<html>
|
||||
@@ -771,12 +791,17 @@ describe('findEntrypoints', () => {
|
||||
|
||||
const entrypoints = await findEntrypoints();
|
||||
|
||||
expect(entrypoints).toEqual([]);
|
||||
expect(entrypoints).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'unlisted',
|
||||
skipped: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exclude option', () => {
|
||||
it('should filter out the background when exclude contains the target browser', async () => {
|
||||
it('should mark the background as skipped when exclude contains the target browser', async () => {
|
||||
globMock.mockResolvedValueOnce(['background.ts']);
|
||||
importEntrypointMock.mockResolvedValue({
|
||||
exclude: [config.browser],
|
||||
@@ -784,10 +809,15 @@ describe('findEntrypoints', () => {
|
||||
|
||||
const entrypoints = await findEntrypoints();
|
||||
|
||||
expect(entrypoints).toEqual([]);
|
||||
expect(entrypoints).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'background',
|
||||
skipped: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter out content scripts when exclude contains the target browser', async () => {
|
||||
it('should mark content scripts as skipped when exclude contains the target browser', async () => {
|
||||
globMock.mockResolvedValueOnce(['example.content.ts']);
|
||||
importEntrypointMock.mockResolvedValue({
|
||||
exclude: [config.browser],
|
||||
@@ -795,10 +825,15 @@ describe('findEntrypoints', () => {
|
||||
|
||||
const entrypoints = await findEntrypoints();
|
||||
|
||||
expect(entrypoints).toEqual([]);
|
||||
expect(entrypoints).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'example',
|
||||
skipped: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter out the popup when exclude contains the target browser', async () => {
|
||||
it('should mark the popup as skipped when exclude contains the target browser', async () => {
|
||||
globMock.mockResolvedValueOnce(['popup.html']);
|
||||
readFileMock.mockResolvedValueOnce(
|
||||
`<html>
|
||||
@@ -810,10 +845,15 @@ describe('findEntrypoints', () => {
|
||||
|
||||
const entrypoints = await findEntrypoints();
|
||||
|
||||
expect(entrypoints).toEqual([]);
|
||||
expect(entrypoints).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'popup',
|
||||
skipped: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter out the options page when exclude contains the target browser', async () => {
|
||||
it('should mark the options page as skipped when exclude contains the target browser', async () => {
|
||||
globMock.mockResolvedValueOnce(['options.html']);
|
||||
readFileMock.mockResolvedValueOnce(
|
||||
`<html>
|
||||
@@ -825,10 +865,15 @@ describe('findEntrypoints', () => {
|
||||
|
||||
const entrypoints = await findEntrypoints();
|
||||
|
||||
expect(entrypoints).toEqual([]);
|
||||
expect(entrypoints).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'options',
|
||||
skipped: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter out an unlisted page when exclude contains the target browser', async () => {
|
||||
it('should mark unlisted pages as skipped when exclude contains the target browser', async () => {
|
||||
globMock.mockResolvedValueOnce(['unlisted.html']);
|
||||
readFileMock.mockResolvedValueOnce(
|
||||
`<html>
|
||||
@@ -840,12 +885,17 @@ describe('findEntrypoints', () => {
|
||||
|
||||
const entrypoints = await findEntrypoints();
|
||||
|
||||
expect(entrypoints).toEqual([]);
|
||||
expect(entrypoints).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'unlisted',
|
||||
skipped: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterEntrypoints option', () => {
|
||||
it('should control entrypoints accessible', async () => {
|
||||
it('should override include/exclude of individual entrypoint options', async () => {
|
||||
globMock.mockResolvedValue([
|
||||
'options/index.html',
|
||||
'popup/index.html',
|
||||
@@ -867,9 +917,25 @@ describe('findEntrypoints', () => {
|
||||
importEntrypointMock.mockResolvedValue({});
|
||||
|
||||
const entrypoints = await findEntrypoints();
|
||||
const names = entrypoints.map((item) => item.name);
|
||||
expect(names).toHaveLength(2);
|
||||
expect(names).toEqual(filterEntrypoints);
|
||||
|
||||
expect(entrypoints).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'injected',
|
||||
skipped: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'options',
|
||||
skipped: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'popup',
|
||||
skipped: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'ui',
|
||||
skipped: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,12 +152,16 @@ describe('groupEntrypoints', () => {
|
||||
options: {
|
||||
type: 'module',
|
||||
},
|
||||
skipped: false,
|
||||
});
|
||||
const popup = fakePopupEntrypoint({
|
||||
skipped: false,
|
||||
});
|
||||
const popup = fakePopupEntrypoint();
|
||||
const sandbox = fakeGenericEntrypoint({
|
||||
inputPath: '/entrypoints/sandbox.html',
|
||||
name: 'sandbox',
|
||||
type: 'sandbox',
|
||||
skipped: false,
|
||||
});
|
||||
|
||||
const actual = groupEntrypoints([background, popup, sandbox]);
|
||||
@@ -165,6 +169,22 @@ describe('groupEntrypoints', () => {
|
||||
expect(actual).toEqual([[background, popup], [sandbox]]);
|
||||
});
|
||||
|
||||
it('should exclude skipped entrypoints from the groups to build', () => {
|
||||
const background = fakeBackgroundEntrypoint({
|
||||
options: {
|
||||
type: 'module',
|
||||
},
|
||||
skipped: false,
|
||||
});
|
||||
const popup = fakePopupEntrypoint({
|
||||
skipped: true,
|
||||
});
|
||||
|
||||
const actual = groupEntrypoints([background, popup]);
|
||||
|
||||
expect(actual).toEqual([[background]]);
|
||||
});
|
||||
|
||||
it.todo(
|
||||
'should group ESM compatible sandbox scripts with sandbox pages',
|
||||
() => {
|
||||
|
||||
@@ -26,7 +26,11 @@ import { wxt } from '../../wxt';
|
||||
* - Background script is changed
|
||||
* - Manifest is different
|
||||
* - Restart browser
|
||||
* - Config file changed (wxt.config.ts, .env, web-ext.config.ts, etc)
|
||||
* - web-ext.config.ts (runner config changes)
|
||||
* - Full dev server restart
|
||||
* - wxt.config.ts (main config file)
|
||||
* - modules/* (any file related to WXT modules)
|
||||
* - .env (environment variable changed could effect build)
|
||||
*/
|
||||
export function detectDevChanges(
|
||||
changedFiles: string[],
|
||||
@@ -38,6 +42,11 @@ export function detectDevChanges(
|
||||
);
|
||||
if (isConfigChange) return { type: 'full-restart' };
|
||||
|
||||
const isWxtModuleChange = some(changedFiles, (file) =>
|
||||
file.startsWith(wxt.config.modulesDir),
|
||||
);
|
||||
if (isWxtModuleChange) return { type: 'full-restart' };
|
||||
|
||||
const isRunnerChange = some(
|
||||
changedFiles,
|
||||
(file) => file === wxt.config.runnerConfig.configFile,
|
||||
|
||||
@@ -65,14 +65,7 @@ export async function findEntrypoints(): Promise<Entrypoint[]> {
|
||||
);
|
||||
if (matchingGlob) {
|
||||
const type = PATH_GLOB_TO_TYPE_MAP[matchingGlob];
|
||||
results.push({
|
||||
name,
|
||||
inputPath,
|
||||
type,
|
||||
skipped:
|
||||
wxt.config.filterEntrypoints != null &&
|
||||
!wxt.config.filterEntrypoints.has(name),
|
||||
});
|
||||
results.push({ name, inputPath, type });
|
||||
}
|
||||
return results;
|
||||
}, []);
|
||||
@@ -84,7 +77,7 @@ export async function findEntrypoints(): Promise<Entrypoint[]> {
|
||||
// Import entrypoints to get their config
|
||||
let hasBackground = false;
|
||||
const env = createExtensionEnvironment();
|
||||
const entrypoints: Entrypoint[] = await env.run(() =>
|
||||
const entrypointsWithoutSkipped: Entrypoint[] = await env.run(() =>
|
||||
Promise.all(
|
||||
entrypointInfos.map(async (info): Promise<Entrypoint> => {
|
||||
const { type } = info;
|
||||
@@ -130,61 +123,44 @@ export async function findEntrypoints(): Promise<Entrypoint[]> {
|
||||
);
|
||||
|
||||
if (wxt.config.command === 'serve' && !hasBackground) {
|
||||
entrypoints.push(
|
||||
entrypointsWithoutSkipped.push(
|
||||
await getBackgroundEntrypoint({
|
||||
inputPath: VIRTUAL_NOOP_BACKGROUND_MODULE_ID,
|
||||
name: 'background',
|
||||
type: 'background',
|
||||
skipped: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Mark entrypoints as skipped or not
|
||||
const entrypoints = entrypointsWithoutSkipped.map((entry) => ({
|
||||
...entry,
|
||||
skipped: isEntrypointSkipped(entry),
|
||||
}));
|
||||
|
||||
wxt.logger.debug('All entrypoints:', entrypoints);
|
||||
const skippedEntrypointNames = entrypointInfos
|
||||
const skippedEntrypointNames = entrypoints
|
||||
.filter((item) => item.skipped)
|
||||
.map((item) => item.name);
|
||||
if (skippedEntrypointNames.length) {
|
||||
wxt.logger.warn(
|
||||
`Filter excluded the following entrypoints:\n${skippedEntrypointNames
|
||||
.map((item) => `${pc.dim('-')} ${pc.cyan(item)}`)
|
||||
.join('\n')}`,
|
||||
[
|
||||
'The following entrypoints have been skipped:',
|
||||
...skippedEntrypointNames.map(
|
||||
(item) => `${pc.dim('-')} ${pc.cyan(item)}`,
|
||||
),
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
const targetEntrypoints = entrypoints.filter((entry) => {
|
||||
const { include, exclude } = entry.options;
|
||||
if (include?.length && exclude?.length) {
|
||||
wxt.logger.warn(
|
||||
`The ${entry.name} entrypoint lists both include and exclude, but only one can be used per entrypoint. Entrypoint ignored.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (exclude?.length && !include?.length) {
|
||||
return !exclude.includes(wxt.config.browser);
|
||||
}
|
||||
if (include?.length && !exclude?.length) {
|
||||
return include.includes(wxt.config.browser);
|
||||
}
|
||||
if (skippedEntrypointNames.includes(entry.name)) {
|
||||
return false;
|
||||
}
|
||||
await wxt.hooks.callHook('entrypoints:resolved', wxt, entrypoints);
|
||||
|
||||
return true;
|
||||
});
|
||||
wxt.logger.debug(`${wxt.config.browser} entrypoints:`, targetEntrypoints);
|
||||
await wxt.hooks.callHook('entrypoints:resolved', wxt, targetEntrypoints);
|
||||
|
||||
return targetEntrypoints;
|
||||
return entrypoints;
|
||||
}
|
||||
|
||||
interface EntrypointInfo {
|
||||
name: string;
|
||||
inputPath: string;
|
||||
type: Entrypoint['type'];
|
||||
/**
|
||||
* @default false
|
||||
*/
|
||||
skipped: boolean;
|
||||
}
|
||||
|
||||
function preventDuplicateEntrypointNames(files: EntrypointInfo[]) {
|
||||
@@ -228,7 +204,7 @@ async function getPopupEntrypoint(
|
||||
const options = await getHtmlEntrypointOptions<PopupEntrypointOptions>(
|
||||
info,
|
||||
{
|
||||
browserStyle: 'browse_style',
|
||||
browserStyle: 'browser_style',
|
||||
exclude: 'exclude',
|
||||
include: 'include',
|
||||
defaultIcon: 'default_icon',
|
||||
@@ -252,7 +228,6 @@ async function getPopupEntrypoint(
|
||||
options: resolvePerBrowserOptions(options, wxt.config.browser),
|
||||
inputPath: info.inputPath,
|
||||
outputDir: wxt.config.outDir,
|
||||
skipped: info.skipped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,7 +237,7 @@ async function getOptionsEntrypoint(
|
||||
const options = await getHtmlEntrypointOptions<OptionsEntrypointOptions>(
|
||||
info,
|
||||
{
|
||||
browserStyle: 'browse_style',
|
||||
browserStyle: 'browser_style',
|
||||
chromeStyle: 'chrome_style',
|
||||
exclude: 'exclude',
|
||||
include: 'include',
|
||||
@@ -275,7 +250,6 @@ async function getOptionsEntrypoint(
|
||||
options: resolvePerBrowserOptions(options, wxt.config.browser),
|
||||
inputPath: info.inputPath,
|
||||
outputDir: wxt.config.outDir,
|
||||
skipped: info.skipped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -293,14 +267,12 @@ async function getUnlistedPageEntrypoint(
|
||||
inputPath: info.inputPath,
|
||||
outputDir: wxt.config.outDir,
|
||||
options,
|
||||
skipped: info.skipped,
|
||||
};
|
||||
}
|
||||
|
||||
async function getUnlistedScriptEntrypoint({
|
||||
inputPath,
|
||||
name,
|
||||
skipped,
|
||||
}: EntrypointInfo): Promise<GenericEntrypoint> {
|
||||
const defaultExport =
|
||||
await wxt.builder.importEntrypoint<UnlistedScriptDefinition>(inputPath);
|
||||
@@ -316,14 +288,12 @@ async function getUnlistedScriptEntrypoint({
|
||||
inputPath,
|
||||
outputDir: wxt.config.outDir,
|
||||
options: resolvePerBrowserOptions(options, wxt.config.browser),
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
async function getBackgroundEntrypoint({
|
||||
inputPath,
|
||||
name,
|
||||
skipped,
|
||||
}: EntrypointInfo): Promise<BackgroundEntrypoint> {
|
||||
let options: Omit<BackgroundDefinition, 'main'> = {};
|
||||
if (inputPath !== VIRTUAL_NOOP_BACKGROUND_MODULE_ID) {
|
||||
@@ -348,14 +318,12 @@ async function getBackgroundEntrypoint({
|
||||
inputPath,
|
||||
outputDir: wxt.config.outDir,
|
||||
options: resolvePerBrowserOptions(options, wxt.config.browser),
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
async function getContentScriptEntrypoint({
|
||||
inputPath,
|
||||
name,
|
||||
skipped,
|
||||
}: EntrypointInfo): Promise<ContentScriptEntrypoint> {
|
||||
const defaultExport =
|
||||
await wxt.builder.importEntrypoint<ContentScriptDefinition>(inputPath);
|
||||
@@ -377,7 +345,6 @@ async function getContentScriptEntrypoint({
|
||||
inputPath,
|
||||
outputDir: resolve(wxt.config.outDir, CONTENT_SCRIPT_OUT_DIR),
|
||||
options: resolvePerBrowserOptions(options, wxt.config.browser),
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -409,7 +376,6 @@ async function getSidepanelEntrypoint(
|
||||
options: resolvePerBrowserOptions(options, wxt.config.browser),
|
||||
inputPath: info.inputPath,
|
||||
outputDir: wxt.config.outDir,
|
||||
skipped: info.skipped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -457,6 +423,29 @@ async function getHtmlEntrypointOptions<T extends BaseEntrypointOptions>(
|
||||
return options;
|
||||
}
|
||||
|
||||
function isEntrypointSkipped(entry: Omit<Entrypoint, 'skipped'>): boolean {
|
||||
if (wxt.config.filterEntrypoints != null) {
|
||||
return !wxt.config.filterEntrypoints.has(entry.name);
|
||||
}
|
||||
|
||||
const { include, exclude } = entry.options;
|
||||
if (include?.length && exclude?.length) {
|
||||
wxt.logger.warn(
|
||||
`The ${entry.name} entrypoint lists both include and exclude, but only one can be used per entrypoint. Entrypoint skipped.`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (exclude?.length && !include?.length) {
|
||||
return exclude.includes(wxt.config.browser);
|
||||
}
|
||||
if (include?.length && !exclude?.length) {
|
||||
return !include.includes(wxt.config.browser);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type']> = {
|
||||
'sandbox.html': 'sandbox',
|
||||
'sandbox/index.html': 'sandbox',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Entrypoint, EntrypointGroup } from '../../../types';
|
||||
|
||||
/**
|
||||
* Entrypoints can be build in groups. HTML pages can all be built together in a single step, while
|
||||
* content scripts must be build individually.
|
||||
* Entrypoints are built in groups. HTML pages can all be built together in a single step,
|
||||
* content scripts must be build individually, etc.
|
||||
*
|
||||
* This function returns the entrypoints put into these types of groups.
|
||||
*/
|
||||
@@ -11,6 +11,8 @@ export function groupEntrypoints(entrypoints: Entrypoint[]): EntrypointGroup[] {
|
||||
const groups: EntrypointGroup[] = [];
|
||||
|
||||
for (const entry of entrypoints) {
|
||||
if (entry.skipped) continue;
|
||||
|
||||
let group = ENTRY_TYPE_TO_GROUP_MAP[entry.type];
|
||||
if (entry.type === 'background' && entry.options.type === 'module') {
|
||||
group = 'esm';
|
||||
|
||||
@@ -29,7 +29,6 @@ export function hashContentScriptOptions(
|
||||
match_about_blank: false,
|
||||
run_at: 'document_idle',
|
||||
all_frames: false,
|
||||
// @ts-expect-error - not in type
|
||||
match_origin_as_fallback: false,
|
||||
world: 'ISOLATED',
|
||||
...simplifiedOptions,
|
||||
@@ -52,7 +51,7 @@ export function mapWxtOptionsToContentScript(
|
||||
css: string[] | undefined,
|
||||
): Manifest.ContentScript {
|
||||
return {
|
||||
matches: options.matches,
|
||||
matches: options.matches ?? [],
|
||||
all_frames: options.allFrames,
|
||||
match_about_blank: options.matchAboutBlank,
|
||||
exclude_globs: options.excludeGlobs,
|
||||
@@ -62,7 +61,6 @@ export function mapWxtOptionsToContentScript(
|
||||
css,
|
||||
js,
|
||||
|
||||
// @ts-expect-error: untyped chrome options
|
||||
match_origin_as_fallback: options.matchOriginAsFallback,
|
||||
world: options.world,
|
||||
};
|
||||
@@ -80,7 +78,6 @@ export function mapWxtOptionsToRegisteredContentScript(
|
||||
runAt: options.runAt,
|
||||
js,
|
||||
css,
|
||||
// @ts-expect-error: Chrome accepts this, not typed in webextension-polyfill (https://developer.chrome.com/docs/extensions/reference/scripting/#type-RegisteredContentScript)
|
||||
world: options.world,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { config } from 'dotenv';
|
||||
import type { TargetBrowser } from '../../types';
|
||||
|
||||
/**
|
||||
* Load environment files based on the current mode.
|
||||
* Load environment files based on the current mode and browser.
|
||||
*/
|
||||
export function loadEnv(mode: string) {
|
||||
export function loadEnv(mode: string, browser: TargetBrowser) {
|
||||
return config({
|
||||
path: [`.env.${mode}.local`, `.env.${mode}`, `.env.local`, `.env`],
|
||||
// Files on top override files below
|
||||
path: [
|
||||
`.env.${mode}.${browser}.local`,
|
||||
`.env.${mode}.${browser}`,
|
||||
`.env.${browser}.local`,
|
||||
`.env.${browser}`,
|
||||
`.env.${mode}.local`,
|
||||
`.env.${mode}`,
|
||||
`.env.local`,
|
||||
`.env`,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,9 +82,16 @@ export async function generateManifest(
|
||||
|
||||
// Add reload command in dev mode
|
||||
if (wxt.config.command === 'serve' && wxt.config.dev.reloadCommand) {
|
||||
if (manifest.commands && Object.keys(manifest.commands).length >= 4) {
|
||||
if (
|
||||
manifest.commands &&
|
||||
// If the following limit is exceeded, Chrome will fail to load the extension.
|
||||
// Error: "Too many commands specified for 'commands': The maximum is 4."
|
||||
Object.values(manifest.commands).filter(
|
||||
(command) => command.suggested_key,
|
||||
).length >= 4
|
||||
) {
|
||||
warnings.push([
|
||||
"Extension already has 4 registered commands, WXT's reload command is disabled",
|
||||
"Extension already has 4 registered commands with suggested keys, WXT's reload command is disabled",
|
||||
]);
|
||||
} else {
|
||||
manifest.commands ??= {};
|
||||
@@ -117,11 +124,12 @@ export async function generateManifest(
|
||||
if (wxt.config.manifestVersion === 2) {
|
||||
convertWebAccessibleResourcesToMv2(manifest);
|
||||
convertActionToMv2(manifest);
|
||||
convertCspToMv2(manifest);
|
||||
moveHostPermissionsToPermissions(manifest);
|
||||
}
|
||||
|
||||
if (wxt.config.manifestVersion === 3) {
|
||||
validateMv3WebAccessbileResources(manifest);
|
||||
validateMv3WebAccessibleResources(manifest);
|
||||
}
|
||||
|
||||
stripKeys(manifest);
|
||||
@@ -143,7 +151,7 @@ export async function generateManifest(
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes suffixes from the version, like X.Y.Z-alpha1 (which brosers don't allow), so it's a
|
||||
* Removes suffixes from the version, like X.Y.Z-alpha1 (which browsers don't allow), so it's a
|
||||
* simple version number, like X or X.Y or X.Y.Z, which browsers allow.
|
||||
*/
|
||||
function simplifyVersion(versionName: string): string {
|
||||
@@ -362,7 +370,7 @@ function addEntrypoints(
|
||||
// at runtime
|
||||
if (wxt.config.command === 'serve' && wxt.config.manifestVersion === 3) {
|
||||
contentScripts.forEach((script) => {
|
||||
script.options.matches.forEach((matchPattern) => {
|
||||
script.options.matches?.forEach((matchPattern) => {
|
||||
addHostPermission(manifest, matchPattern);
|
||||
});
|
||||
});
|
||||
@@ -405,7 +413,7 @@ function addEntrypoints(
|
||||
);
|
||||
}
|
||||
runtimeContentScripts.forEach((script) => {
|
||||
script.options.matches.forEach((matchPattern) => {
|
||||
script.options.matches?.forEach((matchPattern) => {
|
||||
addHostPermission(manifest, matchPattern);
|
||||
});
|
||||
});
|
||||
@@ -467,34 +475,28 @@ function addDevModeCsp(manifest: Manifest.WebExtensionManifest): void {
|
||||
}
|
||||
|
||||
const extensionPagesCsp = new ContentSecurityPolicy(
|
||||
manifest.manifest_version === 3
|
||||
? // @ts-expect-error: extension_pages is not typed
|
||||
(manifest.content_security_policy?.extension_pages ??
|
||||
"script-src 'self' 'wasm-unsafe-eval'; object-src 'self';") // default extension_pages CSP for MV3
|
||||
: (manifest.content_security_policy ??
|
||||
"script-src 'self'; object-src 'self';"), // default CSP for MV2
|
||||
// @ts-expect-error: extension_pages exists, we convert MV2 CSPs to this earlier in the process
|
||||
manifest.content_security_policy?.extension_pages ??
|
||||
(manifest.manifest_version === 3
|
||||
? DEFAULT_MV3_EXTENSION_PAGES_CSP
|
||||
: DEFAULT_MV2_CSP),
|
||||
);
|
||||
const sandboxCsp = new ContentSecurityPolicy(
|
||||
// @ts-expect-error: sandbox is not typed
|
||||
manifest.content_security_policy?.sandbox ??
|
||||
"sandbox allow-scripts allow-forms allow-popups allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval'; child-src 'self';", // default sandbox CSP for MV3
|
||||
manifest.content_security_policy?.sandbox ?? DEFAULT_MV3_SANDBOX_CSP,
|
||||
);
|
||||
|
||||
if (wxt.server) {
|
||||
if (wxt.config.command === 'serve') {
|
||||
extensionPagesCsp.add('script-src', allowedCsp);
|
||||
sandboxCsp.add('script-src', allowedCsp);
|
||||
}
|
||||
|
||||
if (manifest.manifest_version === 3) {
|
||||
manifest.content_security_policy ??= {};
|
||||
// @ts-expect-error: extension_pages is not typed
|
||||
manifest.content_security_policy.extension_pages =
|
||||
extensionPagesCsp.toString();
|
||||
// @ts-expect-error: sandbox is not typed
|
||||
manifest.content_security_policy.sandbox = sandboxCsp.toString();
|
||||
} else {
|
||||
manifest.content_security_policy = extensionPagesCsp.toString();
|
||||
}
|
||||
manifest.content_security_policy ??= {};
|
||||
// @ts-expect-error: extension_pages is not typed
|
||||
manifest.content_security_policy.extension_pages =
|
||||
extensionPagesCsp.toString();
|
||||
// @ts-expect-error: sandbox is not typed
|
||||
manifest.content_security_policy.sandbox = sandboxCsp.toString();
|
||||
}
|
||||
|
||||
function addDevModePermissions(manifest: Manifest.WebExtensionManifest) {
|
||||
@@ -552,7 +554,7 @@ export function getContentScriptCssWebAccessibleResources(
|
||||
|
||||
resources.push({
|
||||
resources: [cssFile],
|
||||
matches: script.options.matches.map((matchPattern) =>
|
||||
matches: script.options.matches?.map((matchPattern) =>
|
||||
stripPathFromMatchPattern(matchPattern),
|
||||
),
|
||||
});
|
||||
@@ -613,7 +615,7 @@ export function stripPathFromMatchPattern(pattern: string) {
|
||||
/**
|
||||
* Converts all MV3 web accessible resources to their MV2 forms. MV3 web accessible resources are
|
||||
* generated in this file, and may be defined by the user in their manifest. In both cases, when
|
||||
* targetting MV2, automatically convert their definitions down to the basic MV2 array.
|
||||
* targeting MV2, automatically convert their definitions down to the basic MV2 array.
|
||||
*/
|
||||
export function convertWebAccessibleResourcesToMv2(
|
||||
manifest: Manifest.WebExtensionManifest,
|
||||
@@ -652,10 +654,21 @@ function convertActionToMv2(manifest: Manifest.WebExtensionManifest): void {
|
||||
manifest.browser_action = manifest.action;
|
||||
}
|
||||
|
||||
function convertCspToMv2(manifest: Manifest.WebExtensionManifest): void {
|
||||
if (
|
||||
typeof manifest.content_security_policy === 'string' ||
|
||||
manifest.content_security_policy?.extension_pages == null
|
||||
)
|
||||
return;
|
||||
|
||||
manifest.content_security_policy =
|
||||
manifest.content_security_policy.extension_pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure all resources are in MV3 format. If not, add a wanring
|
||||
*/
|
||||
export function validateMv3WebAccessbileResources(
|
||||
export function validateMv3WebAccessibleResources(
|
||||
manifest: Manifest.WebExtensionManifest,
|
||||
): void {
|
||||
if (manifest.web_accessible_resources == null) return;
|
||||
@@ -718,3 +731,9 @@ const mv3OnlyKeys = [
|
||||
'side_panel',
|
||||
];
|
||||
const firefoxMv3OnlyKeys = ['host_permissions'];
|
||||
|
||||
const DEFAULT_MV3_EXTENSION_PAGES_CSP =
|
||||
"script-src 'self' 'wasm-unsafe-eval'; object-src 'self';";
|
||||
const DEFAULT_MV3_SANDBOX_CSP =
|
||||
"sandbox allow-scripts allow-forms allow-popups allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval'; child-src 'self';";
|
||||
const DEFAULT_MV2_CSP = "script-src 'self'; object-src 'self';";
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { resolve } from 'path';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import merge from 'lodash.merge';
|
||||
import { Commands, type Manifest } from 'wxt/browser';
|
||||
import type { Manifest } from 'wxt/browser';
|
||||
import {
|
||||
FsCache,
|
||||
ResolvedConfig,
|
||||
@@ -355,13 +355,18 @@ export const fakeBuildStepOutput = fakeObjectCreator<BuildStepOutput>(() => ({
|
||||
entrypoints: fakeArray(fakeEntrypoint),
|
||||
}));
|
||||
|
||||
export const fakeManifestCommand = fakeObjectCreator<Commands.Command>(() => ({
|
||||
description: faker.string.sample(),
|
||||
shortcut: `${faker.helpers.arrayElement(['ctrl', 'alt'])}+${faker.number.int({
|
||||
min: 0,
|
||||
max: 9,
|
||||
})}`,
|
||||
}));
|
||||
export const fakeManifestCommand =
|
||||
fakeObjectCreator<Manifest.WebExtensionManifestCommandsType>(() => ({
|
||||
description: faker.string.sample(),
|
||||
suggested_key: {
|
||||
default: `${faker.helpers.arrayElement(['ctrl', 'alt'])}+${faker.number.int(
|
||||
{
|
||||
min: 0,
|
||||
max: 9,
|
||||
},
|
||||
)}`,
|
||||
},
|
||||
}));
|
||||
|
||||
export const fakeDevServer = fakeObjectCreator<WxtDevServer>(() => ({
|
||||
hostname: 'localhost',
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
InlineConfig,
|
||||
ResolvedConfig,
|
||||
Wxt,
|
||||
WxtCommand,
|
||||
WxtDevServer,
|
||||
WxtHooks,
|
||||
WxtModule,
|
||||
} from '../types';
|
||||
import { InlineConfig, Wxt, WxtCommand, WxtHooks, WxtModule } from '../types';
|
||||
import { resolveConfig } from './resolve-config';
|
||||
import { createHooks } from 'hookable';
|
||||
import { createWxtPackageManager } from './package-managers';
|
||||
@@ -26,7 +18,6 @@ export let wxt: Wxt;
|
||||
export async function registerWxt(
|
||||
command: WxtCommand,
|
||||
inlineConfig: InlineConfig = {},
|
||||
getServer?: (config: ResolvedConfig) => Promise<WxtDevServer>,
|
||||
): Promise<void> {
|
||||
// Default NODE_ENV environment variable before other packages, like vite, do it
|
||||
// See https://github.com/wxt-dev/wxt/issues/873#issuecomment-2254555523
|
||||
@@ -34,8 +25,7 @@ export async function registerWxt(
|
||||
|
||||
const hooks = createHooks<WxtHooks>();
|
||||
const config = await resolveConfig(inlineConfig, command);
|
||||
const server = await getServer?.(config);
|
||||
const builder = await createViteBuilder(config, hooks, server);
|
||||
const builder = await createViteBuilder(config, hooks, () => wxt.server);
|
||||
const pm = await createWxtPackageManager(config.root);
|
||||
|
||||
wxt = {
|
||||
@@ -46,31 +36,38 @@ export async function registerWxt(
|
||||
return config.logger;
|
||||
},
|
||||
async reloadConfig() {
|
||||
// Prevent changing the server port when resolving config multiple times
|
||||
// get-port-please doesn't always return the same port if it was recently closed.
|
||||
if (wxt.config.dev.server?.port) {
|
||||
inlineConfig.dev ??= {};
|
||||
inlineConfig.dev.server ??= {};
|
||||
inlineConfig.dev.server.port = wxt.config.dev.server.port;
|
||||
}
|
||||
|
||||
wxt.config = await resolveConfig(inlineConfig, command);
|
||||
await wxt.hooks.callHook('config:resolved', wxt);
|
||||
},
|
||||
pm,
|
||||
builder,
|
||||
server,
|
||||
server: undefined,
|
||||
};
|
||||
|
||||
// Initialize modules
|
||||
const initModule = async (module: WxtModule<any>) => {
|
||||
if (module.hooks) wxt.hooks.addHooks(module.hooks);
|
||||
await module.setup?.(
|
||||
wxt,
|
||||
// @ts-expect-error: Untyped configKey field
|
||||
module.configKey ? config[module.configKey] : undefined,
|
||||
);
|
||||
};
|
||||
for (const builtinModule of builtinModules) await initModule(builtinModule);
|
||||
for (const userModule of config.userModules) await initModule(userModule);
|
||||
await initWxtModules();
|
||||
}
|
||||
|
||||
export async function initWxtModules() {
|
||||
// Call setup function and add hooks
|
||||
for (const mod of builtinModules) await initWxtModule(mod);
|
||||
for (const mod of wxt.config.userModules) await initWxtModule(mod);
|
||||
|
||||
// Initialize hooks
|
||||
wxt.hooks.addHooks(config.hooks);
|
||||
wxt.hooks.addHooks(wxt.config.hooks);
|
||||
|
||||
// Print order for debugging
|
||||
if (wxt.config.debug) {
|
||||
const order = [
|
||||
...builtinModules.map((module) => module.name),
|
||||
...config.userModules.map((module) =>
|
||||
...wxt.config.userModules.map((module) =>
|
||||
relative(wxt.config.root, module.id),
|
||||
),
|
||||
'wxt.config.ts > hooks',
|
||||
@@ -82,6 +79,23 @@ export async function registerWxt(
|
||||
}
|
||||
|
||||
await wxt.hooks.callHook('ready', wxt);
|
||||
await wxt.hooks.callHook('config:resolved', wxt);
|
||||
}
|
||||
|
||||
async function initWxtModule(module: WxtModule<any>): Promise<void> {
|
||||
if (module.hooks) wxt.hooks.addHooks(module.hooks);
|
||||
await module.setup?.(
|
||||
wxt,
|
||||
// @ts-expect-error: Untyped configKey field
|
||||
module.configKey ? wxt.config[module.configKey] : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unloads WXT modules.
|
||||
*/
|
||||
export function deinitWxtModules(): void {
|
||||
wxt.hooks.removeAllHooks();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getPackageJson } from './utils/package';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { formatDuration } from './utils/time';
|
||||
import { printFileList } from './utils/log/printFileList';
|
||||
import { internalBuild } from './utils/building';
|
||||
import { findEntrypoints, internalBuild } from './utils/building';
|
||||
import { registerWxt, wxt } from './wxt';
|
||||
import JSZip from 'jszip';
|
||||
import glob from 'fast-glob';
|
||||
@@ -55,6 +55,14 @@ export async function zip(config?: InlineConfig): Promise<string[]> {
|
||||
await wxt.hooks.callHook('zip:extension:done', wxt, outZipPath);
|
||||
|
||||
if (wxt.config.zip.zipSources) {
|
||||
const entrypoints = await findEntrypoints();
|
||||
const skippedEntrypoints = entrypoints.filter((entry) => entry.skipped);
|
||||
const excludeSources = [
|
||||
...wxt.config.zip.excludeSources,
|
||||
...skippedEntrypoints.map((entry) =>
|
||||
path.relative(wxt.config.zip.sourcesRoot, entry.inputPath),
|
||||
),
|
||||
].map((paths) => paths.replaceAll('\\', '/'));
|
||||
await wxt.hooks.callHook('zip:sources:start', wxt);
|
||||
const { overrides, files: downloadedPackages } =
|
||||
await downloadPrivatePackages();
|
||||
@@ -65,7 +73,7 @@ export async function zip(config?: InlineConfig): Promise<string[]> {
|
||||
);
|
||||
await zipDir(wxt.config.zip.sourcesRoot, sourcesZipPath, {
|
||||
include: wxt.config.zip.includeSources,
|
||||
exclude: wxt.config.zip.excludeSources,
|
||||
exclude: excludeSources,
|
||||
transform(absolutePath, zipPath, content) {
|
||||
if (zipPath.endsWith('package.json')) {
|
||||
return addOverridesToPackageJson(absolutePath, content, overrides);
|
||||
|
||||
@@ -28,7 +28,7 @@ export function defineWxtModule<TOptions extends WxtModuleOptions>(
|
||||
/**
|
||||
* Adds a TS/JS file as an entrypoint to the project. This file will be bundled
|
||||
* along with the other entrypoints.
|
||||
|
||||
*
|
||||
* If you're publishing the module to NPM, you should probably pre-build the
|
||||
* entrypoint and use `addPublicAssets` instead to copy pre-bundled assets into
|
||||
* the output directory. This will speed up project builds since it just has to
|
||||
@@ -53,7 +53,7 @@ export function defineWxtModule<TOptions extends WxtModuleOptions>(
|
||||
* });
|
||||
*/
|
||||
export function addEntrypoint(wxt: Wxt, entrypoint: Entrypoint): void {
|
||||
wxt.hooks.hook('entrypoints:resolved', (wxt, entrypoints) => {
|
||||
wxt.hooks.hook('entrypoints:resolved', (_, entrypoints) => {
|
||||
entrypoints.push(entrypoint);
|
||||
});
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export function addViteConfig(
|
||||
wxt: Wxt,
|
||||
viteConfig: (env: vite.ConfigEnv) => vite.UserConfig | undefined,
|
||||
): void {
|
||||
wxt.hooks.hook('ready', (wxt) => {
|
||||
wxt.hooks.hook('config:resolved', (wxt) => {
|
||||
const userVite = wxt.config.vite;
|
||||
wxt.config.vite = async (env) => {
|
||||
const fromUser = await userVite(env);
|
||||
@@ -130,7 +130,7 @@ export function addViteConfig(
|
||||
* });
|
||||
*/
|
||||
export function addWxtPlugin(wxt: Wxt, plugin: string): void {
|
||||
wxt.hooks.hook('ready', (wxt) => {
|
||||
wxt.hooks.hook('config:resolved', (wxt) => {
|
||||
wxt.config.plugins.push(plugin);
|
||||
});
|
||||
}
|
||||
@@ -166,7 +166,7 @@ export function addImportPreset(
|
||||
wxt: Wxt,
|
||||
preset: UnimportOptions['presets'][0],
|
||||
): void {
|
||||
wxt.hooks.hook('ready', (wxt) => {
|
||||
wxt.hooks.hook('config:resolved', (wxt) => {
|
||||
if (!wxt.config.imports) return;
|
||||
|
||||
wxt.config.imports.presets ??= [];
|
||||
@@ -205,12 +205,13 @@ export function addImportPreset(
|
||||
* });
|
||||
*/
|
||||
export function addAlias(wxt: Wxt, alias: string, path: string) {
|
||||
wxt.hooks.hook('ready', (wxt) => {
|
||||
wxt.hooks.hook('config:resolved', (wxt) => {
|
||||
const target = resolve(wxt.config.root, path);
|
||||
if (wxt.config.alias[alias] != null) {
|
||||
if (wxt.config.alias[alias] != null && wxt.config.alias[alias] !== target) {
|
||||
wxt.logger.warn(
|
||||
`Skipped adding alias (${alias} => ${target}) because an alias with the same name already exists: ${alias} => ${wxt.config.alias[alias]}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
wxt.config.alias[alias] = target;
|
||||
});
|
||||
|
||||
+2
-719
@@ -1,721 +1,4 @@
|
||||
/**
|
||||
* Simplified storage APIs with support for versioned fields, snapshots, metadata, and item definitions.
|
||||
*
|
||||
* See [the guide](https://wxt.dev/guide/extension-apis/storage.html) for more information.
|
||||
*
|
||||
* @module wxt/storage
|
||||
* @module @wxt-dev/storage
|
||||
*/
|
||||
import { Storage, browser } from 'wxt/browser';
|
||||
import { dequal } from 'dequal/lite';
|
||||
import { logger } from './sandbox/utils/logger';
|
||||
import { toArray } from './core/utils/arrays';
|
||||
import { Mutex } from 'async-mutex';
|
||||
|
||||
export const storage = createStorage();
|
||||
|
||||
function createStorage(): WxtStorage {
|
||||
const drivers: Record<StorageArea, WxtStorageDriver> = {
|
||||
local: createDriver('local'),
|
||||
session: createDriver('session'),
|
||||
sync: createDriver('sync'),
|
||||
managed: createDriver('managed'),
|
||||
};
|
||||
const getDriver = (area: StorageArea) => {
|
||||
const driver = drivers[area];
|
||||
if (driver == null) {
|
||||
const areaNames = Object.keys(drivers).join(', ');
|
||||
throw Error(`Invalid area "${area}". Options: ${areaNames}`);
|
||||
}
|
||||
return driver;
|
||||
};
|
||||
const resolveKey = (key: StorageItemKey) => {
|
||||
const deliminatorIndex = key.indexOf(':');
|
||||
const driverArea = key.substring(0, deliminatorIndex) as StorageArea;
|
||||
const driverKey = key.substring(deliminatorIndex + 1);
|
||||
if (driverKey == null)
|
||||
throw Error(
|
||||
`Storage key should be in the form of "area:key", but received "${key}"`,
|
||||
);
|
||||
|
||||
return {
|
||||
driverArea,
|
||||
driverKey,
|
||||
driver: getDriver(driverArea),
|
||||
};
|
||||
};
|
||||
const getMetaKey = (key: string) => key + '$';
|
||||
const getValueOrFallback = (value: any, fallback: any) =>
|
||||
value ?? fallback ?? null;
|
||||
const getMetaValue = (properties: any) =>
|
||||
typeof properties === 'object' && !Array.isArray(properties)
|
||||
? properties
|
||||
: {};
|
||||
|
||||
const getItem = async (
|
||||
driver: WxtStorageDriver,
|
||||
driverKey: string,
|
||||
opts: GetItemOptions<any> | undefined,
|
||||
) => {
|
||||
const res = await driver.getItem<any>(driverKey);
|
||||
return getValueOrFallback(res, opts?.fallback ?? opts?.defaultValue);
|
||||
};
|
||||
const getMeta = async (driver: WxtStorageDriver, driverKey: string) => {
|
||||
const metaKey = getMetaKey(driverKey);
|
||||
const res = await driver.getItem<any>(metaKey);
|
||||
return getMetaValue(res);
|
||||
};
|
||||
const setItem = async (
|
||||
driver: WxtStorageDriver,
|
||||
driverKey: string,
|
||||
value: any,
|
||||
) => {
|
||||
await driver.setItem(driverKey, value ?? null);
|
||||
};
|
||||
const setMeta = async (
|
||||
driver: WxtStorageDriver,
|
||||
driverKey: string,
|
||||
properties: any | undefined,
|
||||
) => {
|
||||
const metaKey = getMetaKey(driverKey);
|
||||
const existingFields = getMetaValue(await driver.getItem(metaKey));
|
||||
const newFields = { ...existingFields };
|
||||
Object.entries(properties).forEach(([key, value]) => {
|
||||
if (value == null) {
|
||||
delete newFields[key];
|
||||
} else {
|
||||
newFields[key] = value;
|
||||
}
|
||||
});
|
||||
await driver.setItem(metaKey, newFields);
|
||||
};
|
||||
const removeItem = async (
|
||||
driver: WxtStorageDriver,
|
||||
driverKey: string,
|
||||
opts: RemoveItemOptions | undefined,
|
||||
) => {
|
||||
await driver.removeItem(driverKey);
|
||||
if (opts?.removeMeta) {
|
||||
const metaKey = getMetaKey(driverKey);
|
||||
await driver.removeItem(metaKey);
|
||||
}
|
||||
};
|
||||
const removeMeta = async (
|
||||
driver: WxtStorageDriver,
|
||||
driverKey: string,
|
||||
properties: string | string[] | undefined,
|
||||
) => {
|
||||
const metaKey = getMetaKey(driverKey);
|
||||
if (properties == null) {
|
||||
await driver.removeItem(metaKey);
|
||||
} else {
|
||||
const newFields = getMetaValue(await driver.getItem(metaKey));
|
||||
toArray(properties).forEach((field) => delete newFields[field]);
|
||||
await driver.setItem(metaKey, newFields);
|
||||
}
|
||||
};
|
||||
const watch = (
|
||||
driver: WxtStorageDriver,
|
||||
driverKey: string,
|
||||
cb: WatchCallback<any>,
|
||||
) => {
|
||||
return driver.watch(driverKey, cb);
|
||||
};
|
||||
|
||||
const storage: WxtStorage = {
|
||||
getItem: async (key, opts) => {
|
||||
const { driver, driverKey } = resolveKey(key);
|
||||
return await getItem(driver, driverKey, opts);
|
||||
},
|
||||
getItems: async (keys) => {
|
||||
const areaToKeyMap = new Map<StorageArea, string[]>();
|
||||
const keyToOptsMap = new Map<string, GetItemOptions<any> | undefined>();
|
||||
keys.forEach((key) => {
|
||||
let keyStr: StorageItemKey;
|
||||
let opts: GetItemOptions<any> | undefined;
|
||||
if (typeof key === 'string') {
|
||||
keyStr = key;
|
||||
} else {
|
||||
keyStr = key.key;
|
||||
opts = key.options;
|
||||
}
|
||||
const { driverArea, driverKey } = resolveKey(keyStr);
|
||||
const keys = areaToKeyMap.get(driverArea) ?? [];
|
||||
areaToKeyMap.set(driverArea, keys.concat(driverKey));
|
||||
keyToOptsMap.set(keyStr, opts);
|
||||
});
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from(areaToKeyMap.entries()).map(async ([driverArea, keys]) => {
|
||||
const driverResults = await drivers[driverArea].getItems(keys);
|
||||
return driverResults.map((driverResult) => {
|
||||
const key = `${driverArea}:${driverResult.key}` as StorageItemKey;
|
||||
const opts = keyToOptsMap.get(key);
|
||||
const value = getValueOrFallback(
|
||||
driverResult.value,
|
||||
opts?.fallback ?? opts?.defaultValue,
|
||||
);
|
||||
return { key, value };
|
||||
});
|
||||
}),
|
||||
);
|
||||
return results.flat();
|
||||
},
|
||||
getMeta: async (key) => {
|
||||
const { driver, driverKey } = resolveKey(key);
|
||||
return await getMeta(driver, driverKey);
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
const { driver, driverKey } = resolveKey(key);
|
||||
await setItem(driver, driverKey, value);
|
||||
},
|
||||
setItems: async (values) => {
|
||||
const areaToKeyValueMap = new Map<
|
||||
StorageArea,
|
||||
Array<{ key: string; value: any }>
|
||||
>();
|
||||
values.forEach(({ key, value }) => {
|
||||
const { driverArea, driverKey } = resolveKey(key);
|
||||
const values = areaToKeyValueMap.get(driverArea) ?? [];
|
||||
areaToKeyValueMap.set(
|
||||
driverArea,
|
||||
values.concat({ key: driverKey, value }),
|
||||
);
|
||||
});
|
||||
await Promise.all(
|
||||
Array.from(areaToKeyValueMap.entries()).map(
|
||||
async ([driverArea, values]) => {
|
||||
const driver = getDriver(driverArea);
|
||||
await driver.setItems(values);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
setMeta: async (key, properties) => {
|
||||
const { driver, driverKey } = resolveKey(key);
|
||||
await setMeta(driver, driverKey, properties);
|
||||
},
|
||||
removeItem: async (key, opts) => {
|
||||
const { driver, driverKey } = resolveKey(key);
|
||||
await removeItem(driver, driverKey, opts);
|
||||
},
|
||||
removeItems: async (keys) => {
|
||||
const areaToKeysMap = new Map<StorageArea, string[]>();
|
||||
keys.forEach((key) => {
|
||||
let keyStr: StorageItemKey;
|
||||
let opts: RemoveItemOptions | undefined;
|
||||
if (typeof key === 'string') {
|
||||
keyStr = key;
|
||||
} else {
|
||||
keyStr = key.key;
|
||||
opts = key.options;
|
||||
}
|
||||
const { driverArea, driverKey } = resolveKey(keyStr);
|
||||
const areaKeys = areaToKeysMap.get(driverArea) ?? [];
|
||||
areaKeys.push(driverKey);
|
||||
if (opts?.removeMeta) {
|
||||
areaKeys.push(getMetaKey(driverKey));
|
||||
}
|
||||
areaToKeysMap.set(driverArea, areaKeys);
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
Array.from(areaToKeysMap.entries()).map(async ([driverArea, keys]) => {
|
||||
const driver = getDriver(driverArea);
|
||||
await driver.removeItems(keys);
|
||||
}),
|
||||
);
|
||||
},
|
||||
removeMeta: async (key, properties) => {
|
||||
const { driver, driverKey } = resolveKey(key);
|
||||
await removeMeta(driver, driverKey, properties);
|
||||
},
|
||||
snapshot: async (base, opts) => {
|
||||
const driver = getDriver(base);
|
||||
const data = await driver.snapshot();
|
||||
opts?.excludeKeys?.forEach((key) => {
|
||||
delete data[key];
|
||||
delete data[getMetaKey(key)];
|
||||
});
|
||||
return data;
|
||||
},
|
||||
restoreSnapshot: async (base, data) => {
|
||||
const driver = getDriver(base);
|
||||
await driver.restoreSnapshot(data);
|
||||
},
|
||||
watch: (key, cb) => {
|
||||
const { driver, driverKey } = resolveKey(key);
|
||||
return watch(driver, driverKey, cb);
|
||||
},
|
||||
unwatch() {
|
||||
Object.values(drivers).forEach((driver) => {
|
||||
driver.unwatch();
|
||||
});
|
||||
},
|
||||
defineItem: (key, opts?: WxtStorageItemOptions<any>) => {
|
||||
const { driver, driverKey } = resolveKey(key);
|
||||
|
||||
const { version: targetVersion = 1, migrations = {} } = opts ?? {};
|
||||
if (targetVersion < 1) {
|
||||
throw Error(
|
||||
'Storage item version cannot be less than 1. Initial versions should be set to 1, not 0.',
|
||||
);
|
||||
}
|
||||
const migrate = async () => {
|
||||
const driverMetaKey = getMetaKey(driverKey);
|
||||
const [{ value }, { value: meta }] = await driver.getItems([
|
||||
driverKey,
|
||||
driverMetaKey,
|
||||
]);
|
||||
if (value == null) return;
|
||||
|
||||
const currentVersion = meta?.v ?? 1;
|
||||
if (currentVersion > targetVersion) {
|
||||
throw Error(
|
||||
`Version downgrade detected (v${currentVersion} -> v${targetVersion}) for "${key}"`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Running storage migration for ${key}: v${currentVersion} -> v${targetVersion}`,
|
||||
);
|
||||
const migrationsToRun = Array.from(
|
||||
{ length: targetVersion - currentVersion },
|
||||
(_, i) => currentVersion + i + 1,
|
||||
);
|
||||
let migratedValue = value;
|
||||
for (const migrateToVersion of migrationsToRun) {
|
||||
migratedValue =
|
||||
(await migrations?.[migrateToVersion]?.(migratedValue)) ??
|
||||
migratedValue;
|
||||
}
|
||||
await driver.setItems([
|
||||
{ key: driverKey, value: migratedValue },
|
||||
{ key: driverMetaKey, value: { ...meta, v: targetVersion } },
|
||||
]);
|
||||
logger.debug(
|
||||
`Storage migration completed for ${key} v${targetVersion}`,
|
||||
{ migratedValue },
|
||||
);
|
||||
};
|
||||
const migrationsDone =
|
||||
opts?.migrations == null
|
||||
? Promise.resolve()
|
||||
: migrate().catch((err) => {
|
||||
logger.error(`Migration failed for ${key}`, err);
|
||||
});
|
||||
|
||||
const initMutex = new Mutex();
|
||||
|
||||
const getFallback = () => opts?.fallback ?? opts?.defaultValue ?? null;
|
||||
|
||||
const getOrInitValue = () =>
|
||||
initMutex.runExclusive(async () => {
|
||||
const value = await driver.getItem<any>(driverKey);
|
||||
// Don't init value if it already exists or the init function isn't provided
|
||||
if (value != null || opts?.init == null) return value;
|
||||
|
||||
const newValue = await opts.init();
|
||||
await driver.setItem<any>(driverKey, newValue);
|
||||
return newValue;
|
||||
});
|
||||
|
||||
// Initialize the value once migrations have finished
|
||||
migrationsDone.then(getOrInitValue);
|
||||
|
||||
return {
|
||||
get defaultValue() {
|
||||
return getFallback();
|
||||
},
|
||||
get fallback() {
|
||||
return getFallback();
|
||||
},
|
||||
getValue: async () => {
|
||||
await migrationsDone;
|
||||
if (opts?.init) {
|
||||
return await getOrInitValue();
|
||||
} else {
|
||||
return await getItem(driver, driverKey, opts);
|
||||
}
|
||||
},
|
||||
getMeta: async () => {
|
||||
await migrationsDone;
|
||||
return await getMeta(driver, driverKey);
|
||||
},
|
||||
setValue: async (value) => {
|
||||
await migrationsDone;
|
||||
return await setItem(driver, driverKey, value);
|
||||
},
|
||||
setMeta: async (properties) => {
|
||||
await migrationsDone;
|
||||
return await setMeta(driver, driverKey, properties);
|
||||
},
|
||||
removeValue: async (opts) => {
|
||||
await migrationsDone;
|
||||
return await removeItem(driver, driverKey, opts);
|
||||
},
|
||||
removeMeta: async (properties) => {
|
||||
await migrationsDone;
|
||||
return await removeMeta(driver, driverKey, properties);
|
||||
},
|
||||
watch: (cb) =>
|
||||
watch(driver, driverKey, (newValue, oldValue) =>
|
||||
cb(newValue ?? getFallback(), oldValue ?? getFallback()),
|
||||
),
|
||||
migrate,
|
||||
};
|
||||
},
|
||||
};
|
||||
return storage;
|
||||
}
|
||||
|
||||
function createDriver(storageArea: StorageArea): WxtStorageDriver {
|
||||
const getStorageArea = () => {
|
||||
if (browser.runtime == null) {
|
||||
throw Error(
|
||||
[
|
||||
"'wxt/storage' must be loaded in a web extension environment",
|
||||
'\n - If thrown during a build, see https://github.com/wxt-dev/wxt/issues/371',
|
||||
" - If thrown during tests, mock 'wxt/browser' correctly. See https://wxt.dev/guide/go-further/testing.html\n",
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
if (browser.storage == null) {
|
||||
throw Error(
|
||||
"You must add the 'storage' permission to your manifest to use 'wxt/storage'",
|
||||
);
|
||||
}
|
||||
|
||||
const area = browser.storage[storageArea];
|
||||
if (area == null)
|
||||
throw Error(`"browser.storage.${storageArea}" is undefined`);
|
||||
return area;
|
||||
};
|
||||
const watchListeners = new Set<
|
||||
(changes: Storage.StorageAreaOnChangedChangesType) => void
|
||||
>();
|
||||
return {
|
||||
getItem: async (key) => {
|
||||
const res = await getStorageArea().get(key);
|
||||
return res[key];
|
||||
},
|
||||
getItems: async (keys) => {
|
||||
const result = await getStorageArea().get(keys);
|
||||
return keys.map((key) => ({ key, value: result[key] ?? null }));
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
if (value == null) {
|
||||
await getStorageArea().remove(key);
|
||||
} else {
|
||||
await getStorageArea().set({ [key]: value });
|
||||
}
|
||||
},
|
||||
setItems: async (values) => {
|
||||
const map = values.reduce<Record<string, unknown>>(
|
||||
(map, { key, value }) => {
|
||||
map[key] = value;
|
||||
return map;
|
||||
},
|
||||
{},
|
||||
);
|
||||
await getStorageArea().set(map);
|
||||
},
|
||||
removeItem: async (key) => {
|
||||
await getStorageArea().remove(key);
|
||||
},
|
||||
removeItems: async (keys) => {
|
||||
await getStorageArea().remove(keys);
|
||||
},
|
||||
snapshot: async () => {
|
||||
return await getStorageArea().get();
|
||||
},
|
||||
restoreSnapshot: async (data) => {
|
||||
await getStorageArea().set(data);
|
||||
},
|
||||
watch(key, cb) {
|
||||
const listener = (changes: Storage.StorageAreaOnChangedChangesType) => {
|
||||
const change = changes[key];
|
||||
if (change == null) return;
|
||||
if (dequal(change.newValue, change.oldValue)) return;
|
||||
cb(change.newValue ?? null, change.oldValue ?? null);
|
||||
};
|
||||
getStorageArea().onChanged.addListener(listener);
|
||||
watchListeners.add(listener);
|
||||
return () => {
|
||||
getStorageArea().onChanged.removeListener(listener);
|
||||
watchListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
unwatch() {
|
||||
watchListeners.forEach((listener) => {
|
||||
getStorageArea().onChanged.removeListener(listener);
|
||||
});
|
||||
watchListeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface WxtStorage {
|
||||
/**
|
||||
* Get an item from storage, or return `null` if it doesn't exist.
|
||||
*
|
||||
* @example
|
||||
* await storage.getItem<number>("local:installDate");
|
||||
*/
|
||||
getItem<TValue>(
|
||||
key: StorageItemKey,
|
||||
opts: GetItemOptions<TValue> & { fallback: TValue },
|
||||
): Promise<TValue>;
|
||||
|
||||
getItem<TValue>(
|
||||
key: StorageItemKey,
|
||||
opts?: GetItemOptions<TValue>,
|
||||
): Promise<TValue | null>;
|
||||
|
||||
/**
|
||||
* Get multiple items from storage. The return order is guaranteed to be the same as the order
|
||||
* requested.
|
||||
*
|
||||
* @example
|
||||
* await storage.getItems(["local:installDate", "session:someCounter"]);
|
||||
*/
|
||||
getItems(
|
||||
keys: Array<
|
||||
StorageItemKey | { key: StorageItemKey; options?: GetItemOptions<any> }
|
||||
>,
|
||||
): Promise<Array<{ key: StorageItemKey; value: any }>>;
|
||||
/**
|
||||
* Return an object containing metadata about the key. Object is stored at `key + "$"`. If value
|
||||
* is not an object, it returns an empty object.
|
||||
*
|
||||
* @example
|
||||
* await storage.getMeta("local:installDate");
|
||||
*/
|
||||
getMeta<T extends Record<string, unknown>>(key: StorageItemKey): Promise<T>;
|
||||
/**
|
||||
* Set a value in storage. Setting a value to `null` or `undefined` is equivalent to calling
|
||||
* `removeItem`.
|
||||
*
|
||||
* @example
|
||||
* await storage.setItem<number>("local:installDate", Date.now());
|
||||
*/
|
||||
setItem<T>(key: StorageItemKey, value: T | null): Promise<void>;
|
||||
/**
|
||||
* Set multiple values in storage. If a value is set to `null` or `undefined`, the key is removed.
|
||||
*
|
||||
* @example
|
||||
* await storage.setItem([
|
||||
* { key: "local:installDate", value: Date.now() },
|
||||
* { key: "session:someCounter, value: 5 },
|
||||
* ]);
|
||||
*/
|
||||
setItems(values: Array<{ key: StorageItemKey; value: any }>): Promise<void>;
|
||||
/**
|
||||
* Sets metadata properties. If some properties are already set, but are not included in the
|
||||
* `properties` parameter, they will not be removed.
|
||||
*
|
||||
* @example
|
||||
* await storage.setMeta("local:installDate", { appVersion });
|
||||
*/
|
||||
setMeta<T extends Record<string, unknown>>(
|
||||
key: StorageItemKey,
|
||||
properties: T | null,
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Removes an item from storage.
|
||||
*
|
||||
* @example
|
||||
* await storage.removeItem("local:installDate");
|
||||
*/
|
||||
removeItem(key: StorageItemKey, opts?: RemoveItemOptions): Promise<void>;
|
||||
/**
|
||||
* Remove a list of keys from storage.
|
||||
*/
|
||||
removeItems(
|
||||
keys: Array<
|
||||
StorageItemKey | { key: StorageItemKey; options?: RemoveItemOptions }
|
||||
>,
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Remove the entire metadata for a key, or specific properties by name.
|
||||
*
|
||||
* @example
|
||||
* // Remove all metadata properties from the item
|
||||
* await storage.removeMeta("local:installDate");
|
||||
*
|
||||
* // Remove only specific the "v" field
|
||||
* await storage.removeMeta("local:installDate", "v")
|
||||
*/
|
||||
removeMeta(
|
||||
key: StorageItemKey,
|
||||
properties?: string | string[],
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Return all the items in storage.
|
||||
*/
|
||||
snapshot(
|
||||
base: StorageArea,
|
||||
opts?: SnapshotOptions,
|
||||
): Promise<Record<string, unknown>>;
|
||||
/**
|
||||
* Restores the results of `snapshot`. If new properties have been saved since the snapshot, they are
|
||||
* not overridden. Only values existing in the snapshot are overridden.
|
||||
*/
|
||||
restoreSnapshot(base: StorageArea, data: any): Promise<void>;
|
||||
/**
|
||||
* Watch for changes to a specific key in storage.
|
||||
*/
|
||||
watch<T>(key: StorageItemKey, cb: WatchCallback<T | null>): Unwatch;
|
||||
/**
|
||||
* Remove all watch listeners.
|
||||
*/
|
||||
unwatch(): void;
|
||||
|
||||
/**
|
||||
* Define a storage item with a default value, type, or versioning.
|
||||
*
|
||||
* Read full docs: https://wxt.dev/guide/extension-apis/storage.html#defining-storage-items
|
||||
*/
|
||||
defineItem<TValue, TMetadata extends Record<string, unknown> = {}>(
|
||||
key: StorageItemKey,
|
||||
): WxtStorageItem<TValue | null, TMetadata>;
|
||||
defineItem<TValue, TMetadata extends Record<string, unknown> = {}>(
|
||||
key: StorageItemKey,
|
||||
options: WxtStorageItemOptions<TValue>,
|
||||
): WxtStorageItem<TValue, TMetadata>;
|
||||
}
|
||||
|
||||
interface WxtStorageDriver {
|
||||
getItem<T>(key: string): Promise<T | null>;
|
||||
getItems(keys: string[]): Promise<{ key: string; value: any }[]>;
|
||||
setItem<T>(key: string, value: T | null): Promise<void>;
|
||||
setItems(values: Array<{ key: string; value: any }>): Promise<void>;
|
||||
removeItem(key: string): Promise<void>;
|
||||
removeItems(keys: string[]): Promise<void>;
|
||||
snapshot(): Promise<Record<string, unknown>>;
|
||||
restoreSnapshot(data: Record<string, unknown>): Promise<void>;
|
||||
watch<T>(key: string, cb: WatchCallback<T | null>): Unwatch;
|
||||
unwatch(): void;
|
||||
}
|
||||
|
||||
export interface WxtStorageItem<
|
||||
TValue,
|
||||
TMetadata extends Record<string, unknown>,
|
||||
> {
|
||||
/**
|
||||
* @deprecated Renamed to `fallback`, use it instead.
|
||||
*/
|
||||
defaultValue: TValue;
|
||||
/**
|
||||
* The value provided by the `fallback` option.
|
||||
*/
|
||||
fallback: TValue;
|
||||
/**
|
||||
* Get the latest value from storage.
|
||||
*/
|
||||
getValue(): Promise<TValue>;
|
||||
/**
|
||||
* Get metadata.
|
||||
*/
|
||||
getMeta(): Promise<NullablePartial<TMetadata>>;
|
||||
/**
|
||||
* Set the value in storage.
|
||||
*/
|
||||
setValue(value: TValue): Promise<void>;
|
||||
/**
|
||||
* Set metadata properties.
|
||||
*/
|
||||
setMeta(properties: NullablePartial<TMetadata>): Promise<void>;
|
||||
/**
|
||||
* Remove the value from storage.
|
||||
*/
|
||||
removeValue(opts?: RemoveItemOptions): Promise<void>;
|
||||
/**
|
||||
* Remove all metadata or certain properties from metadata.
|
||||
*/
|
||||
removeMeta(properties?: string[]): Promise<void>;
|
||||
/**
|
||||
* Listen for changes to the value in storage.
|
||||
*/
|
||||
watch(cb: WatchCallback<TValue>): Unwatch;
|
||||
/**
|
||||
* If there are migrations defined on the storage item, migrate to the latest version.
|
||||
*
|
||||
* **This function is ran automatically whenever the extension updates**, so you don't have to call it
|
||||
* manually.
|
||||
*/
|
||||
migrate(): Promise<void>;
|
||||
}
|
||||
|
||||
export type StorageArea = 'local' | 'session' | 'sync' | 'managed';
|
||||
export type StorageItemKey = `${StorageArea}:${string}`;
|
||||
|
||||
export interface GetItemOptions<T> {
|
||||
/**
|
||||
* @deprecated Renamed to `fallback`, use it instead.
|
||||
*/
|
||||
defaultValue?: T;
|
||||
/**
|
||||
* Default value returned when `getItem` would otherwise return `null`.
|
||||
*/
|
||||
fallback?: T;
|
||||
}
|
||||
|
||||
export interface RemoveItemOptions {
|
||||
/**
|
||||
* Optionally remove metadata when deleting a key.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
removeMeta?: boolean;
|
||||
}
|
||||
|
||||
export interface SnapshotOptions {
|
||||
/**
|
||||
* Exclude a list of keys. The storage area prefix should be removed since the snapshot is for a
|
||||
* specific storage area already.
|
||||
*/
|
||||
excludeKeys?: string[];
|
||||
}
|
||||
|
||||
export interface WxtStorageItemOptions<T> {
|
||||
/**
|
||||
* @deprecated Renamed to `fallback`, use it instead.
|
||||
*/
|
||||
defaultValue?: T;
|
||||
/**
|
||||
* Default value returned when `getValue` would otherwise return `null`.
|
||||
*/
|
||||
fallback?: T;
|
||||
/**
|
||||
* If passed, a value in storage will be initialized immediately after
|
||||
* defining the storage item. This function returns the value that will be
|
||||
* saved to storage during the initialization process if a value doesn't
|
||||
* already exist.
|
||||
*/
|
||||
init?: () => T | Promise<T>;
|
||||
/**
|
||||
* Provide a version number for the storage item to enable migrations. When changing the version
|
||||
* in the future, migration functions will be ran on application startup.
|
||||
*/
|
||||
version?: number;
|
||||
/**
|
||||
* A map of version numbers to the functions used to migrate the data to that version.
|
||||
*/
|
||||
migrations?: Record<number, (oldValue: any) => any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `Partial`, but includes `| null`. It makes all the properties of an object optional and
|
||||
* nullable.
|
||||
*/
|
||||
export type NullablePartial<T> = {
|
||||
[key in keyof T]+?: T[key] | undefined | null;
|
||||
};
|
||||
/**
|
||||
* Callback called when a value in storage is changed.
|
||||
*/
|
||||
export type WatchCallback<T> = (newValue: T, oldValue: T) => void;
|
||||
/**
|
||||
* Call to remove a watch listener
|
||||
*/
|
||||
export type Unwatch = () => void;
|
||||
export * from '@wxt-dev/storage';
|
||||
|
||||
+37
-11
@@ -45,6 +45,7 @@ export interface InlineConfig {
|
||||
* A list of entrypoint names (`"popup"`, `"options"`, etc.) to build. Will speed up the build if
|
||||
* your extension has lots of entrypoints, and you don't need to build all of them to develop a
|
||||
* feature.
|
||||
* If specified, this completely overrides the `include`/`exclude` option provided per-entrypoint.
|
||||
*/
|
||||
filterEntrypoints?: string[];
|
||||
/**
|
||||
@@ -271,7 +272,7 @@ export interface InlineConfig {
|
||||
analysis?: {
|
||||
/**
|
||||
* Explicitly include bundle analysis when running `wxt build`. This can be overridden by the
|
||||
* command line `--analysis` option.
|
||||
* command line `--analyze` option.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
@@ -588,7 +589,7 @@ export interface BackgroundEntrypointOptions extends BaseEntrypointOptions {
|
||||
|
||||
export interface BaseContentScriptEntrypointOptions
|
||||
extends BaseEntrypointOptions {
|
||||
matches: PerBrowserOption<Manifest.ContentScript['matches']>;
|
||||
matches?: PerBrowserOption<Manifest.ContentScript['matches']>;
|
||||
/**
|
||||
* See https://developer.chrome.com/docs/extensions/mv3/content_scripts/
|
||||
* @default "documentIdle"
|
||||
@@ -728,7 +729,14 @@ export interface BaseEntrypoint {
|
||||
* subdirectory of it.
|
||||
*/
|
||||
outputDir: string;
|
||||
skipped: boolean;
|
||||
/**
|
||||
* When true, the entrypoint will not be built by WXT. Normally this is set
|
||||
* based on the `filterEntrypoints` config or the entrypoint's
|
||||
* `include`/`exclude` options defined inside the file.
|
||||
*
|
||||
* See https://wxt.dev/guide/essentials/target-different-browsers.html#filtering-entrypoints
|
||||
*/
|
||||
skipped?: boolean;
|
||||
}
|
||||
|
||||
export interface GenericEntrypoint extends BaseEntrypoint {
|
||||
@@ -1100,11 +1108,16 @@ export type HookResult = Promise<void> | void;
|
||||
|
||||
export interface WxtHooks {
|
||||
/**
|
||||
* Called after WXT initialization, when the WXT instance is ready to work.
|
||||
* Called after WXT modules are initialized, when the WXT instance is ready to
|
||||
* be used. `wxt.server` isn't available yet, use `server:created` to get it.
|
||||
* @param wxt The configured WXT object
|
||||
* @returns Promise
|
||||
*/
|
||||
ready: (wxt: Wxt) => HookResult;
|
||||
/**
|
||||
* Called whenever config is loaded or reloaded. Use this hook to modify config by modifying `wxt.config`.
|
||||
* @param wxt The configured WXT object
|
||||
*/
|
||||
'config:resolved': (wxt: Wxt) => HookResult;
|
||||
/**
|
||||
* Called before WXT writes .wxt/tsconfig.json and .wxt/wxt.d.ts, allowing
|
||||
* addition of custom references and declarations in wxt.d.ts, or directly
|
||||
@@ -1192,39 +1205,52 @@ export interface WxtHooks {
|
||||
* @param wxt The configured WXT object
|
||||
*/
|
||||
'zip:start': (wxt: Wxt) => HookResult;
|
||||
|
||||
/**
|
||||
* Called before zipping the extension files.
|
||||
* @param wxt The configured WXT object
|
||||
*/
|
||||
'zip:extension:start': (wxt: Wxt) => HookResult;
|
||||
|
||||
/**
|
||||
* Called after zipping the extension files.
|
||||
* @param wxt The configured WXT object
|
||||
* @param zipPath The path to the created extension zip file
|
||||
*/
|
||||
'zip:extension:done': (wxt: Wxt, zipPath: string) => HookResult;
|
||||
|
||||
/**
|
||||
* Called before zipping the source files (for Firefox).
|
||||
* @param wxt The configured WXT object
|
||||
*/
|
||||
'zip:sources:start': (wxt: Wxt) => HookResult;
|
||||
|
||||
/**
|
||||
* Called after zipping the source files (for Firefox).
|
||||
* @param wxt The configured WXT object
|
||||
* @param zipPath The path to the created sources zip file
|
||||
*/
|
||||
'zip:sources:done': (wxt: Wxt, zipPath: string) => HookResult;
|
||||
|
||||
/**
|
||||
* Called after the entire zip process is complete.
|
||||
* @param wxt The configured WXT object
|
||||
* @param zipFiles An array of paths to all created zip files
|
||||
*/
|
||||
'zip:done': (wxt: Wxt, zipFiles: string[]) => HookResult;
|
||||
/**
|
||||
* Called when the dev server is created (and `wxt.server` is assigned). Server has not been started yet.
|
||||
* @param wxt The configured WXT object
|
||||
* @param server Same as `wxt.server`, the object WXT uses to control the dev server.
|
||||
*/
|
||||
'server:created': (wxt: Wxt, server: WxtDevServer) => HookResult;
|
||||
/**
|
||||
* Called when the dev server is started.
|
||||
* @param wxt The configured WXT object
|
||||
* @param server Same as `wxt.server`, the object WXT uses to control the dev server.
|
||||
*/
|
||||
'server:started': (wxt: Wxt, server: WxtDevServer) => HookResult;
|
||||
/**
|
||||
* Called when the dev server is stopped.
|
||||
* @param wxt The configured WXT object
|
||||
* @param server Same as `wxt.server`, the object WXT uses to control the dev server.
|
||||
*/
|
||||
'server:closed': (wxt: Wxt, server: WxtDevServer) => HookResult;
|
||||
}
|
||||
|
||||
export interface Wxt {
|
||||
@@ -1239,7 +1265,7 @@ export interface Wxt {
|
||||
*/
|
||||
logger: Logger;
|
||||
/**
|
||||
* Reload config file and update the `config` field with the result.
|
||||
* Reload config file and update `wxt.config` with the result.
|
||||
*/
|
||||
reloadConfig: () => Promise<void>;
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"src/browser/index.ts",
|
||||
"src/browser/chrome.ts",
|
||||
"src/index.ts",
|
||||
"src/storage.ts",
|
||||
"src/modules.ts"
|
||||
"src/modules.ts",
|
||||
"src/storage.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Removed sub-ids from rendered links. When you link to the same footnote multiple times, the link would look like `[3.2]` instead of just `[3]`. Didn't like how that looked, so this patch removes that function.
|
||||
@@ -0,0 +1,79 @@
|
||||
diff --git a/dist/index.cjs.js b/dist/index.cjs.js
|
||||
index 806448c967261a61288f0faa0633a91f77d35968..6812dd84bbbb7176af4115a287677454fa562883 100644
|
||||
--- a/dist/index.cjs.js
|
||||
+++ b/dist/index.cjs.js
|
||||
@@ -13,14 +13,14 @@ function render_footnote_anchor_name(tokens, idx, options, env /*, slf */) {
|
||||
}
|
||||
function render_footnote_caption(tokens, idx /*, options, env, slf */) {
|
||||
let n = Number(tokens[idx].meta.id + 1).toString();
|
||||
- if (tokens[idx].meta.subId > 0) n += `:${tokens[idx].meta.subId}`;
|
||||
+ // if (tokens[idx].meta.subId > 0) n += `:${tokens[idx].meta.subId}`;
|
||||
return `[${n}]`;
|
||||
}
|
||||
function render_footnote_ref(tokens, idx, options, env, slf) {
|
||||
const id = slf.rules.footnote_anchor_name(tokens, idx, options, env, slf);
|
||||
const caption = slf.rules.footnote_caption(tokens, idx, options, env, slf);
|
||||
let refid = id;
|
||||
- if (tokens[idx].meta.subId > 0) refid += `:${tokens[idx].meta.subId}`;
|
||||
+ // if (tokens[idx].meta.subId > 0) refid += `:${tokens[idx].meta.subId}`;
|
||||
return `<sup class="footnote-ref"><a href="#fn${id}" id="fnref${refid}">${caption}</a></sup>`;
|
||||
}
|
||||
function render_footnote_block_open(tokens, idx, options) {
|
||||
@@ -31,7 +31,7 @@ function render_footnote_block_close() {
|
||||
}
|
||||
function render_footnote_open(tokens, idx, options, env, slf) {
|
||||
let id = slf.rules.footnote_anchor_name(tokens, idx, options, env, slf);
|
||||
- if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
|
||||
+ // if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
|
||||
return `<li id="fn${id}" class="footnote-item">`;
|
||||
}
|
||||
function render_footnote_close() {
|
||||
@@ -39,7 +39,7 @@ function render_footnote_close() {
|
||||
}
|
||||
function render_footnote_anchor(tokens, idx, options, env, slf) {
|
||||
let id = slf.rules.footnote_anchor_name(tokens, idx, options, env, slf);
|
||||
- if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
|
||||
+ // if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
/* ↩ with escape code to prevent display as Apple Emoji on iOS */
|
||||
return ` <a href="#fnref${id}" class="footnote-backref">\u21a9\uFE0E</a>`;
|
||||
diff --git a/index.mjs b/index.mjs
|
||||
index 48277ca67206f248b9deb0058e9a7d69dbc07702..718e3e527b2513e4f6f59cba9d4526a36d58f6bc 100644
|
||||
--- a/index.mjs
|
||||
+++ b/index.mjs
|
||||
@@ -17,7 +17,7 @@ function render_footnote_anchor_name (tokens, idx, options, env/*, slf */) {
|
||||
function render_footnote_caption (tokens, idx/*, options, env, slf */) {
|
||||
let n = Number(tokens[idx].meta.id + 1).toString()
|
||||
|
||||
- if (tokens[idx].meta.subId > 0) n += `:${tokens[idx].meta.subId}`
|
||||
+ // if (tokens[idx].meta.subId > 0) n += `:${tokens[idx].meta.subId}`
|
||||
|
||||
return `[${n}]`
|
||||
}
|
||||
@@ -27,7 +27,7 @@ function render_footnote_ref (tokens, idx, options, env, slf) {
|
||||
const caption = slf.rules.footnote_caption(tokens, idx, options, env, slf)
|
||||
let refid = id
|
||||
|
||||
- if (tokens[idx].meta.subId > 0) refid += `:${tokens[idx].meta.subId}`
|
||||
+ // if (tokens[idx].meta.subId > 0) refid += `:${tokens[idx].meta.subId}`
|
||||
|
||||
return `<sup class="footnote-ref"><a href="#fn${id}" id="fnref${refid}">${caption}</a></sup>`
|
||||
}
|
||||
@@ -45,7 +45,7 @@ function render_footnote_block_close () {
|
||||
function render_footnote_open (tokens, idx, options, env, slf) {
|
||||
let id = slf.rules.footnote_anchor_name(tokens, idx, options, env, slf)
|
||||
|
||||
- if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`
|
||||
+ // if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`
|
||||
|
||||
return `<li id="fn${id}" class="footnote-item">`
|
||||
}
|
||||
@@ -57,7 +57,7 @@ function render_footnote_close () {
|
||||
function render_footnote_anchor (tokens, idx, options, env, slf) {
|
||||
let id = slf.rules.footnote_anchor_name(tokens, idx, options, env, slf)
|
||||
|
||||
- if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`
|
||||
+ // if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`
|
||||
|
||||
/* ↩ with escape code to prevent display as Apple Emoji on iOS */
|
||||
return ` <a href="#fnref${id}" class="footnote-backref">\u21a9\uFE0E</a>`
|
||||
Generated
+1137
-779
File diff suppressed because it is too large
Load Diff
@@ -7,5 +7,7 @@ export default {
|
||||
'typedoc-vitepress-theme',
|
||||
// Manually manage version so a single version is used:
|
||||
'esbuild',
|
||||
// Maintained manually to match min-node version
|
||||
'@types/node',
|
||||
],
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user