Compare commits
152 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2faba91ddb | |||
| fe8e2ed23b | |||
| 95369ff2c9 | |||
| 829525f824 | |||
| 9ad04d325b | |||
| f218c30825 | |||
| fdee7c615c | |||
| b34754b363 | |||
| be74af40f9 | |||
| baebe28a3e | |||
| 3b9f395a7d | |||
| 95367203bf | |||
| 6fb1463a32 | |||
| b727453509 | |||
| 446fd90bea | |||
| 78f97f32a2 | |||
| 459662a6c1 | |||
| b150a52471 | |||
| 028c601515 | |||
| af382ef065 | |||
| 91ef7f2ab5 | |||
| 0bf7279c8f | |||
| 62525d33c6 | |||
| 88ff40e301 | |||
| 1a72a0cdc6 | |||
| 2b07b4bc12 | |||
| 06c09e7c87 | |||
| 30b96ac87c | |||
| 2e9b5cffe8 | |||
| 3e1641a6ec | |||
| d64ca52bb0 | |||
| 8c645fa1ce | |||
| 79c9c89923 | |||
| 11d93fe724 | |||
| 039c561a80 | |||
| 64a704fc72 | |||
| 4fbf137d15 | |||
| ff065ab88b | |||
| 7fdf3fb968 | |||
| 7ebd451409 | |||
| a7a48a8a75 | |||
| 27298b7f22 | |||
| 565ce93a9a | |||
| 0bc6088b6a | |||
| 2fb99e8fbb | |||
| cd2050c534 | |||
| 5b557d0224 | |||
| 0137d79ba0 | |||
| e05fbf4fb5 | |||
| 72e6ca5063 | |||
| af9e842fe0 | |||
| 230962686f | |||
| 413cd9354f | |||
| 64a42de31f | |||
| f7f03e2669 | |||
| 1dc7c34bfd | |||
| 0fd5c3846c | |||
| e42e92f9df | |||
| e45c91f941 | |||
| 8f71d80f94 | |||
| 78e896e5fb | |||
| 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 | |||
| 90c2b7f9bf | |||
| c431dafe08 | |||
| 3786c7b6d1 | |||
| 0416ff7ec2 | |||
| 26cbef5a42 | |||
| e98f29fddb | |||
| 93e6dd6db7 | |||
| 7879fabbad | |||
| a861dc1736 | |||
| f75c5ca1aa | |||
| e8e00e4e04 | |||
| 15adc77d23 | |||
| c73a4b6a96 | |||
| d96840b2f5 | |||
| e4886af90a | |||
| c69e99db3f | |||
| 55113d654d | |||
| 50d2170844 | |||
| ee53bb0a5e | |||
| 5216d75209 | |||
| d0623fe173 | |||
| e0d0618325 | |||
| 563bd4e108 | |||
| 107803bd8f | |||
| 06fd7e4eac | |||
| eba8788a76 | |||
| 8ace11f3cb | |||
| 328acbbfe7 | |||
| f99c1aa7b5 | |||
| 5217279070 |
@@ -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 }}
|
||||
|
||||
@@ -7,12 +7,14 @@ on:
|
||||
default: wxt
|
||||
type: choice
|
||||
options:
|
||||
- analytics
|
||||
- auto-icons
|
||||
- i18n
|
||||
- module-react
|
||||
- module-solid
|
||||
- module-svelte
|
||||
- module-vue
|
||||
- storage
|
||||
- unocss
|
||||
- wxt
|
||||
|
||||
|
||||
@@ -7,13 +7,15 @@ on:
|
||||
default: wxt
|
||||
type: choice
|
||||
options:
|
||||
- wxt
|
||||
- module-react
|
||||
- module-vue
|
||||
- module-svelte
|
||||
- module-solid
|
||||
- analytics
|
||||
- auto-icons
|
||||
- i18n
|
||||
- module-react
|
||||
- module-solid
|
||||
- module-svelte
|
||||
- module-vue
|
||||
- storage
|
||||
- wxt
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
@@ -21,6 +23,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:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
aaronklinker1@gmail.com.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
@@ -32,6 +32,7 @@ Here are some helpful commands:
|
||||
|
||||
```sh
|
||||
# Build WXT package
|
||||
cd packages/wxt
|
||||
pnpm build
|
||||
```
|
||||
|
||||
@@ -144,3 +145,24 @@ pnpm dlx taze -r
|
||||
```
|
||||
|
||||
Configuration is in [`taze.config.ts`](./taze.config.ts).
|
||||
|
||||
## Install Unreleased Versions
|
||||
|
||||
This repo uses https://pkg.pr.new to publish versions of all it's packages for almost every commit. You can install them via:
|
||||
|
||||
```sh
|
||||
npm i https://pkg.pr.new/[package-name]@[ref]
|
||||
```
|
||||
|
||||
Or use one of the shorthands:
|
||||
|
||||
```sh
|
||||
# Install the latest build of `wxt` from a PR:
|
||||
npm i https://pkg.pr.new/wxt@1283
|
||||
|
||||
# Install the latest build of `@wxt-dev/module-react` on the `main` branch
|
||||
npm i https://pkg.pr.new/@wxt-dev/module-react@main
|
||||
|
||||
# Install `@wxt-dev/storage` from a specific commit:
|
||||
npm i https://pkg.pr.new/@wxt-dev/module-react@426f907
|
||||
```
|
||||
|
||||
@@ -75,6 +75,12 @@ Or see the [installation guide](https://wxt.dev/guide/installation.html) to get
|
||||
- 📏 Bundle analysis
|
||||
- ⬇️ Download and bundle remote URL imports
|
||||
|
||||
## Sponsors
|
||||
|
||||
WXT is a [MIT-licensed](https://github.com/wxt-dev/wxt/blob/main/LICENSE) open source project with its ongoing development made possible entirely by the support of these awesome backers. If you'd like to join them, please consider [sponsoring WXT's development](https://github.com/sponsors/wxt-dev).
|
||||
|
||||
<a href="https://github.com/sponsors/wxt-dev"><img alt="WXT Sponsors" src="https://raw.githubusercontent.com/wxt-dev/static/refs/heads/main/sponsorkit/sponsors.svg"></a>
|
||||
|
||||
## Contributors
|
||||
|
||||
<!-- automd:contributors author="aklinker1" license="MIT" github="wxt-dev/wxt" -->
|
||||
|
||||
@@ -4,11 +4,8 @@ import useListExtensionDetails, {
|
||||
ChromeExtension,
|
||||
} from '../composables/useListExtensionDetails';
|
||||
|
||||
// Add extension IDs here. Order doesn't matter, will be sorted by weekly active users
|
||||
// During the transition from chrome.google.com/webstore to
|
||||
// chromewebstore.google.com, queue.wxt.dev might return null for your
|
||||
// extension. If it does, use "<slug>/<id>" instead of just the ID. The slug
|
||||
// can be retrieved from the URL of the item on chromewebstore.google.com
|
||||
// Add extension IDs here. Order doesn't matter, will be sorted by a combination of weekly active users and rating.
|
||||
// Change the commit message or PR title to: "docs: Added "[extension name]" to the homepage"
|
||||
const chromeExtensionIds = [
|
||||
'ocfdgncpifmegplaglcnglhioflaimkd', // GitHub: Better Line Counts
|
||||
'mgmdkjcljneegjfajchedjpdhbadklcf', // Anime Skip Player
|
||||
@@ -16,7 +13,7 @@ const chromeExtensionIds = [
|
||||
'elfaihghhjjoknimpccccmkioofjjfkf', // StayFree - Website Blocker & Web Analytics
|
||||
'okifoaikfmpfcamplcfjkpdnhfodpkil', // Doozy: Ai Made Easy
|
||||
'lknmjhcajhfbbglglccadlfdjbaiifig', // tl;dv - Record, Transcribe & ChatGPT for Google Meet
|
||||
'youtube中文配音/oglffgiaiekgeicdgkdlnlkhliajdlja', // Youtube中文配音
|
||||
'oglffgiaiekgeicdgkdlnlkhliajdlja', // Youtube中文配音
|
||||
'agjnjboanicjcpenljmaaigopkgdnihi', // PreMiD
|
||||
'aiakblgmlabokilgljkglggnpflljdgp', // Markdown Sticky Notes
|
||||
'nomnkbngkijpffepcgbbofhcnafpkiep', // DocVersionRedirector
|
||||
@@ -53,6 +50,18 @@ const chromeExtensionIds = [
|
||||
'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
|
||||
'hmdcmlfkchdmnmnmheododdhjedfccka', // Eye Dropper
|
||||
'eihpmapodnppeemkhkbhikmggfojdkjd', // Cursorful - Screen Recorder with Auto Zoom
|
||||
'hjjkgbibknbahijglkffklflidncplkn', // Show IP – Live View of Website IPs for Developers
|
||||
'ilbikcehnpkmldojkcmlldkoelofnbde', // Strong Password Generator
|
||||
'ocllfkhcdopiafndigclebelbecaiocp', // ZenGram: Mindful Instagram, Your Way
|
||||
];
|
||||
|
||||
const { data, err, isLoading } = useListExtensionDetails(chromeExtensionIds);
|
||||
|
||||
@@ -13,11 +13,12 @@ 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';
|
||||
@@ -61,6 +62,16 @@ export default defineConfig({
|
||||
alt: 'WXT logo',
|
||||
},
|
||||
|
||||
footer: {
|
||||
message: [
|
||||
'<a class="light-netlify" href="https://www.netlify.com"> <img src="https://www.netlify.com/v3/img/components/netlify-color-bg.svg" alt="Deploys by Netlify" style="display: inline;" /></a>',
|
||||
'<a class="dark-netlify" href="https://www.netlify.com"> <img src="https://www.netlify.com/v3/img/components/netlify-color-accent.svg" alt="Deploys by Netlify" style="display: inline;" /></a>',
|
||||
'Released under the <a href="https://github.com/wxt-dev/wxt/blob/main/LICENSE">MIT License</a>.',
|
||||
].join('<br/>'),
|
||||
copyright:
|
||||
'Copyright © 2023-present <a href="https://github.com/aklinker1">Aaron Klinker</a>',
|
||||
},
|
||||
|
||||
editLink: {
|
||||
pattern: 'https://github.com/wxt-dev/wxt/edit/main/docs/:path',
|
||||
},
|
||||
@@ -87,7 +98,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'),
|
||||
|
||||
@@ -1,72 +1,28 @@
|
||||
/* Colors */
|
||||
:root {
|
||||
--wxt-c-green: #53bc4a;
|
||||
--wxt-c-green-1: #0b8a00;
|
||||
--wxt-c-green-2: #096600;
|
||||
--wxt-c-green-3: #096600;
|
||||
--wxt-c-green-soft: rgba(11, 138, 0, 0.14);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--wxt-c-green-1: #67d45e;
|
||||
--wxt-c-green-2: #4fa048;
|
||||
--wxt-c-green-3: #447e3f;
|
||||
--wxt-c-green-2: #329929;
|
||||
--wxt-c-green-3: #21651b;
|
||||
--wxt-c-green-soft: rgba(103, 212, 94, 0.14);
|
||||
}
|
||||
|
||||
/* https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css */
|
||||
|
||||
:root {
|
||||
--vp-c-brand: var(--wxt-c-green);
|
||||
--vp-c-brand-1: var(--wxt-c-green-1);
|
||||
--vp-c-brand-2: var(--wxt-c-green-2);
|
||||
--vp-c-brand-3: var(--wxt-c-green-3);
|
||||
|
||||
--vp-button-brand-bg: var(--wxt-c-green);
|
||||
--vp-button-brand-hover-bg: var(--wxt-c-green-2);
|
||||
--vp-button-brand-active-bg: var(--wxt-c-green-3);
|
||||
|
||||
--vp-code-link-color: var(--wxt-c-green);
|
||||
|
||||
/* --vp-c-text-1: var(--wxt-c-green-1); */
|
||||
|
||||
--vp-button-brand-text: var(--vp-c-black);
|
||||
--vp-button-brand-hover-text: var(--vp-c-black);
|
||||
--vp-button-brand-active-text: var(--vp-c-black);
|
||||
|
||||
--vp-custom-block-tip-border: var(--wxt-c-green);
|
||||
--vp-custom-block-tip-text: var(--wxt-c-green);
|
||||
|
||||
/* --vp-code-block-bg: #222422;
|
||||
--vp-code-copy-code-bg: #313431;
|
||||
--vp-code-copy-code-hover-bg: #3c403c; */
|
||||
|
||||
--vp-custom-block-tip-bg: var(--vp-code-block-bg);
|
||||
--vp-custom-block-info-bg: var(--vp-code-block-bg);
|
||||
|
||||
--vp-code-color: #476582;
|
||||
--vp-c-brand-soft: var(--wxt-c-green-soft);
|
||||
}
|
||||
|
||||
.vp-doc a {
|
||||
color: var(--wxt-c-green);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--vp-c-bg: #131413;
|
||||
|
||||
--vp-c-bg-soft: #1a1b1a;
|
||||
--vp-c-bg-soft-up: #1f201f;
|
||||
--vp-c-bg-soft-down: #262926;
|
||||
--vp-c-bg-soft-mute: #242424;
|
||||
|
||||
--vp-c-bg-alt: #171817;
|
||||
|
||||
--vp-c-default: #313136;
|
||||
--vp-c-default-1: #3a3a3c;
|
||||
--vp-c-default-2: #505053;
|
||||
--vp-c-default-3: #2c2c30;
|
||||
--vp-c-default-soft: #252529;
|
||||
|
||||
--vp-code-block-bg: #191a19;
|
||||
--vp-code-copy-code-bg: #212321;
|
||||
--vp-code-copy-code-hover-bg: #292d29;
|
||||
|
||||
--vp-custom-block-info-bg: #191a19;
|
||||
|
||||
--vp-code-color: #c9def1;
|
||||
}
|
||||
/* Customize Individual Components */
|
||||
|
||||
.vp-doc .no-vertical-dividers th,
|
||||
.vp-doc .no-vertical-dividers td {
|
||||
@@ -97,3 +53,16 @@ body {
|
||||
vertical-align: middle;
|
||||
background-color: var(--vp-c-default-2);
|
||||
}
|
||||
|
||||
.light-netlify {
|
||||
display: inline;
|
||||
}
|
||||
.dark .light-netlify {
|
||||
display: none;
|
||||
}
|
||||
.dark-netlify {
|
||||
display: none;
|
||||
}
|
||||
.dark .dark-netlify {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 131 KiB |
@@ -77,6 +77,84 @@ export default defineContentScript({
|
||||
|
||||
## WASM
|
||||
|
||||
:::warning 🚧 Under construction
|
||||
These docs will be coming soon!
|
||||
:::
|
||||
How a `.wasm` file is loaded varies greatly between packages, but most follow a basic setup: Use a JS API to load and execute the `.wasm` file.
|
||||
|
||||
For an extension, that means two things:
|
||||
|
||||
1. The `.wasm` file needs to be present in output folder so it can be loaded.
|
||||
2. You must import the JS API to load and initialize the `.wasm` file, usually provided by the NPM package.
|
||||
|
||||
For an example, let's say you have a content script needs to parse TS code into AST. We'll use [`@oxc-parser/wasm`](https://www.npmjs.com/package/@oxc-parser/wasm) to do it!
|
||||
|
||||
First, we need to copy the `.wasm` file to the output directory. We'll do it with a [WXT module](/guide/essentials/wxt-modules):
|
||||
|
||||
```ts
|
||||
// modules/oxc-parser-wasm.ts
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export default defineWxtModule((wxt) => {
|
||||
wxt.hook('build:publicAssets', (_, assets) => {
|
||||
assets.push({
|
||||
absoluteSrc: resolve(
|
||||
'node_modules/@oxc-parser/wasm/web/oxc_parser_wasm_bg.wasm',
|
||||
),
|
||||
relativeDest: 'oxc_parser_wasm_bg.wasm',
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run `wxt build`, and you should see the WASM file copied into your `.output/chrome-mv3` folder!
|
||||
|
||||
Next, since this is in a content script and we'll be fetching the WASM file over the network to load it, we need to add the file to the `web_accessible_resources`:
|
||||
|
||||
```ts
|
||||
// wxt.config.ts
|
||||
export default defineConfig({
|
||||
manifest: {
|
||||
web_accessible_resources: [
|
||||
{
|
||||
// We'll use this matches in the cotent script as well
|
||||
matches: ['*://*.github.com/*'],
|
||||
// Use the same path as `relativeDest` from the WXT module
|
||||
resources: ['/oxc_parser_wasm_bg.wasm'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
And finally, we need to load and initialize the `.wasm` file inside the content script to use it:
|
||||
|
||||
```ts
|
||||
// entrypoints/content.ts
|
||||
import initWasm, { parseSync } from '@oxc-parser/wasm';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: '*://*.github.com/*',
|
||||
async main(ctx) {
|
||||
if (!location.pathname.endsWith('.ts')) return;
|
||||
|
||||
// Get text from GitHub
|
||||
const code = document.getElementById(
|
||||
'read-only-cursor-text-area',
|
||||
)?.textContent;
|
||||
if (!code) return;
|
||||
const sourceFilename = document.getElementById('file-name-id')?.textContent;
|
||||
if (!sourceFilename) return;
|
||||
|
||||
// Load the WASM file:
|
||||
await initWasm({
|
||||
module_or_path: browser.runtime.getURL('/oxc_parser_wasm_bg.wasm'),
|
||||
});
|
||||
|
||||
// Once loaded, we can use `parseSync`!
|
||||
const ast = parseSync(code, { sourceFilename });
|
||||
console.log(ast);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
This code is taken directly from `@oxc-parser/wasm` docs with one exception: We manually pass in a file path. In a standard NodeJS or web project, the default path works just fine so you don't have to pass anything in. However, extensions are different. You should always explicitly pass in the full URL to the WASM file in your output directory, which is what `browser.runtime.getURL` returns.
|
||||
|
||||
Run your extension, and you should see OXC parse the TS file!
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -156,6 +156,11 @@ Alternatively, you can use [`@wxt-dev/auto-icons`](https://www.npmjs.com/package
|
||||
|
||||
> [Chrome docs](https://developer.chrome.com/docs/extensions/reference/permissions/)
|
||||
|
||||
Most of the time, you need to manually add permissions to your manifest. Only in a few specific situations are permissions added automatically:
|
||||
|
||||
- During development: the `tabs` and `scripting` permissions will be added to enable hot reloading.
|
||||
- When a `sidepanel` entrypoint is present: The `sidepanel` permission is added.
|
||||
|
||||
```ts
|
||||
export default defineConfig({
|
||||
manifest: {
|
||||
|
||||
@@ -548,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!');
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -571,6 +576,39 @@ For MV3, `injectScript` is synchronous and the injected script will be evaluated
|
||||
However for MV2, `injectScript` has to `fetch` the script's text content and create an inline `<script>` block. This means for MV2, your script is injected asynchronously and it will not be evaluated at the same time as your content script's `run_at`.
|
||||
:::
|
||||
|
||||
## Mounting UI to dynamic element
|
||||
|
||||
In many cases, you may need to mount a UI to a DOM element that does not exist at the time the web page is initially loaded. To handle this, use the `autoMount` API to automatically mount the UI when the target element appears dynamically and unmount it when the element disappears. In WXT, the `anchor` option is used to target the element, enabling automatic mounting and unmounting based on its appearance and removal.
|
||||
|
||||
```ts
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
|
||||
main(ctx) {
|
||||
const ui = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
// It observes the anchor
|
||||
anchor: '#your-target-dynamic-element',
|
||||
onMount: (container) => {
|
||||
// Append children to the container
|
||||
const app = document.createElement('p');
|
||||
app.textContent = '...';
|
||||
container.append(app);
|
||||
},
|
||||
});
|
||||
|
||||
// Call autoMount to observe anchor element for add/remove.
|
||||
ui.autoMount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
:::tip
|
||||
When the `ui.remove` is called, `autoMount` also stops.
|
||||
:::
|
||||
|
||||
See the [API Reference](/api/reference/wxt/client/interfaces/ContentScriptUi.html#automount) for the complete list of options.
|
||||
|
||||
## Dealing with SPAs
|
||||
|
||||
It is difficult to write content scripts for SPAs (single page applications) and websites using HTML5 history mode for navigation because content scripts are only ran on full page reloads. SPAs and websites that take advantage of HTML5 history mode **_do not perform a full reload when changing paths_**, and thus your content script isn't going to be ran when you expect it to be.
|
||||
|
||||
@@ -304,14 +304,14 @@ 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>
|
||||
|
||||
<!-- Customize the manifest options -->
|
||||
<meta name="manifest.open_in_tab" content="true|false" />
|
||||
<meta name="manifest.chrome_style" content="true|false" />
|
||||
<meta name="manifest.browser_style" content="true|false" />
|
||||
|
||||
<!-- Set include/exclude if the page should be removed from some builds -->
|
||||
<meta name="manifest.include" content="['chrome', ...]" />
|
||||
<meta name="manifest.exclude" content="['chrome', ...]" />
|
||||
@@ -339,7 +339,11 @@ Follow the [Devtools Example](https://github.com/wxt-dev/examples/tree/main/exam
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Set the `action.default_title` in the manifest -->
|
||||
<title>Default Popup Title</title>
|
||||
|
||||
<!-- Customize the manifest options -->
|
||||
<meta
|
||||
name="manifest.default_icon"
|
||||
content="{
|
||||
@@ -350,6 +354,7 @@ Follow the [Devtools Example](https://github.com/wxt-dev/examples/tree/main/exam
|
||||
/>
|
||||
<meta name="manifest.type" content="page_action|browser_action" />
|
||||
<meta name="manifest.browser_style" content="true|false" />
|
||||
|
||||
<!-- Set include/exclude if the page should be removed from some builds -->
|
||||
<meta name="manifest.include" content="['chrome', ...]" />
|
||||
<meta name="manifest.exclude" content="['chrome', ...]" />
|
||||
@@ -384,6 +389,7 @@ Firefox does not support sandboxed pages.
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Title</title>
|
||||
|
||||
<!-- Set include/exclude if the page should be removed from some builds -->
|
||||
<meta name="manifest.include" content="['chrome', ...]" />
|
||||
<meta name="manifest.exclude" content="['chrome', ...]" />
|
||||
@@ -416,6 +422,8 @@ In Chrome, side panels use the `side_panel` API, while Firefox uses the `sidebar
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Default Side Panel Title</title>
|
||||
|
||||
<!-- Customize the manifest options -->
|
||||
<meta
|
||||
name="manifest.default_icon"
|
||||
content="{
|
||||
@@ -426,6 +434,7 @@ In Chrome, side panels use the `side_panel` API, while Firefox uses the `sidebar
|
||||
/>
|
||||
<meta name="manifest.open_at_install" content="true|false" />
|
||||
<meta name="manifest.browser_style" content="true|false" />
|
||||
|
||||
<!-- Set include/exclude if the page should be removed from some builds -->
|
||||
<meta name="manifest.include" content="['chrome', ...]" />
|
||||
<meta name="manifest.exclude" content="['chrome', ...]" />
|
||||
@@ -475,6 +484,7 @@ body {
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Title</title>
|
||||
|
||||
<!-- Set include/exclude if the page should be removed from some builds -->
|
||||
<meta name="manifest.include" content="['chrome', ...]" />
|
||||
<meta name="manifest.exclude" content="['chrome', ...]" />
|
||||
|
||||
@@ -20,7 +20,7 @@ In your background script, set `type: "module"`:
|
||||
|
||||
```ts
|
||||
export default defineBackground({
|
||||
type: 'module', // !code ++
|
||||
type: 'module', // [!code ++]
|
||||
main() {
|
||||
// ...
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
},
|
||||
@@ -167,6 +167,31 @@ This file could then be loaded at runtime:
|
||||
const res = await fetch(browser.runtime.getURL('/some-text.txt'));
|
||||
```
|
||||
|
||||
#### Add custom entrypoints
|
||||
|
||||
Once the existing files under the `entrypoints/` directory have been discovered, the `entrypoints:found` hook can be used to add custom entrypoints.
|
||||
|
||||
:::info
|
||||
The `entrypoints:found` hook is triggered before validation is carried out on the list of entrypoints. Thus, any custom entrypoints will still be checked for duplicate names and logged during debugging.
|
||||
:::
|
||||
|
||||
```ts
|
||||
import { defineWxtModule } from 'wxt/modules';
|
||||
|
||||
export default defineWxtModule({
|
||||
setup(wxt) {
|
||||
wxt.hook('entrypoints:found', (_, entrypointInfos) => {
|
||||
// Add your new entrypoint
|
||||
entrypointInfos.push({
|
||||
name: 'my-custom-script',
|
||||
inputPath: 'path/to/custom-script.js',
|
||||
type: 'content-script',
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Generate runtime module
|
||||
|
||||
Create a file in `.wxt`, add an alias to import it, and add auto-imports for exported variables.
|
||||
|
||||
@@ -9,7 +9,7 @@ Check out the [comparison](/guide/resources/compare) to see how WXT compares to
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -18,9 +18,17 @@ To list the content scripts registered during development, open the service work
|
||||
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.
|
||||
Component libraries place their CSS in the document's `<head>` by default. When using `createShadowRootUi`, 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:
|
||||
|
||||
@@ -30,7 +38,7 @@ To fix this, you need to tell your component library to insert it's CSS inside t
|
||||
|
||||
> 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:
|
||||
`createShadowRootUi` 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 ++]
|
||||
|
||||
@@ -109,27 +109,6 @@ You have two options:
|
||||
```
|
||||
2. Rename your `modules` directory to something else.
|
||||
|
||||
## v0.18.0 → v0.18.5
|
||||
|
||||
> When this version was released, it was not considered a breaking change... but it should have been.
|
||||
|
||||
### New `modules/` Directory
|
||||
|
||||
WXT now recognizes the `modules/` directory as a folder containing [WXT modules](/guide/essentials/wxt-modules).
|
||||
|
||||
If you already have `<srcDir>/modules` or `<srcDir>/Modules` directory, `wxt prepare` and other commands will fail.
|
||||
|
||||
You have two options:
|
||||
|
||||
1. [Recommended] Keep your files where they are and tell WXT to look in a different folder:
|
||||
```ts
|
||||
// wxt.config.ts
|
||||
export default defineConfig({
|
||||
modulesDir: 'wxt-modules', // defaults to "modules"
|
||||
});
|
||||
```
|
||||
2. Rename your `modules` directory to something else.
|
||||
|
||||
## v0.17.0 → v0.18.0
|
||||
|
||||
### Automatic MV3 `host_permissions` to MV2 `permissions`
|
||||
|
||||
+9
-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
|
||||
@@ -73,9 +73,15 @@ features:
|
||||
linkText: Read docs
|
||||
---
|
||||
|
||||
## Sponsors
|
||||
|
||||
WXT is a [MIT-licensed](https://github.com/wxt-dev/wxt/blob/main/LICENSE) open source project with its ongoing development made possible entirely by the support of these awesome backers. If you'd like to join them, please consider [sponsoring WXT's development](https://github.com/sponsors/wxt-dev).
|
||||
|
||||
<a href="https://github.com/sponsors/wxt-dev"><img alt="WXT Sponsors" src="https://raw.githubusercontent.com/wxt-dev/static/refs/heads/main/sponsorkit/sponsors-wide.svg"></a>
|
||||
|
||||
## 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 +93,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 />
|
||||
|
||||
+39
-15
@@ -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,7 +75,7 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -330,4 +354,4 @@ await storage.setItems([
|
||||
]);
|
||||
```
|
||||
|
||||
Refer to the [API Reference](/api/reference/wxt/storage/interfaces/WxtStorage) for types and examples of how to use all the bulk APIs.
|
||||
Refer to the [API Reference](/api/reference/@wxt-dev/storage/interfaces/WxtStorage) for types and examples of how to use all the bulk APIs.
|
||||
|
||||
@@ -20,7 +20,7 @@ Set Framerate 50
|
||||
# Terminal theme with WXT brand colors (which was taken from the website)
|
||||
# Based on the standard charmbracelet/vhs theme:
|
||||
# https://github.com/charmbracelet/vhs/blob/88e634f4a10bbe305b6aea9a12b4d8dc3dd7f31c/style.go#L7-L28
|
||||
Set Theme {"background": "#1a1b1a", "foreground": "#dddddd", "black": "#282a2e", "brightBlack": "#4d4d4d", "red": "#D74E6F", "brightRed": "#FE5F86", "green": "#67d45e", "brightGreen": "#67d45e", "yellow": "#D3E561", "brightYellow": "#EBFF71", "blue": "#8056FF", "brightBlue": "#9B79FF", "magenta": "#ED61D7", "brightMagenta": "#FF7AEA", "cyan": "#04D7D7", "brightCyan": "#00FEFE", "white": "#bfbfbf", "brightWhite": "#e6e6e6", "indigo": "#5B56E0"}
|
||||
Set Theme {"background": "#161618", "foreground": "#dddddd", "black": "#282a2e", "brightBlack": "#4d4d4d", "red": "#D74E6F", "brightRed": "#FE5F86", "green": "#67d45e", "brightGreen": "#67d45e", "yellow": "#D3E561", "brightYellow": "#EBFF71", "blue": "#8056FF", "brightBlue": "#9B79FF", "magenta": "#ED61D7", "brightMagenta": "#FF7AEA", "cyan": "#04D7D7", "brightCyan": "#00FEFE", "white": "#bfbfbf", "brightWhite": "#e6e6e6", "indigo": "#5B56E0"}
|
||||
Set FontSize 32
|
||||
|
||||
# Terminal settings
|
||||
|
||||
+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
|
||||
}
|
||||
}
|
||||
|
||||
+7
-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,24 +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",
|
||||
"markdown-it-footnote": "^4.0.0",
|
||||
"nano-spawn": "^0.1.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": {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# WXT Analytics
|
||||
|
||||
Report analytics events from your web extension extension.
|
||||
|
||||
## Supported Analytics Providers
|
||||
|
||||
- [Google Analytics 4 (Measurement Protocol)](#google-analytics-4-measurement-protocol)
|
||||
- [Umami](#umami)
|
||||
|
||||
## Install With WXT
|
||||
|
||||
1. Install the NPM package:
|
||||
```bash
|
||||
pnpm i @wxt-dev/analytics
|
||||
```
|
||||
2. In your `wxt.config.ts`, add the WXT module:
|
||||
```ts
|
||||
export default defineConfig({
|
||||
modules: ['@wxt-dev/analytics/module'],
|
||||
});
|
||||
```
|
||||
3. In your `<srcDir>/app.config.ts`, add a provider:
|
||||
|
||||
```ts
|
||||
// <srcDir>/app.config.ts
|
||||
import { umami } from '@wxt-dev/analytics/providers/umami';
|
||||
|
||||
export default defineAppConfig({
|
||||
analytics: {
|
||||
debug: true,
|
||||
providers: [
|
||||
// ...
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
4. Then use the `#analytics` module to report events:
|
||||
|
||||
```ts
|
||||
import { analytics } from '#analytics';
|
||||
|
||||
await analytics.track('some-event');
|
||||
await analytics.page();
|
||||
await analytics.identify('some-user-id');
|
||||
analytics.autoTrack(document.body);
|
||||
```
|
||||
|
||||
## Install Without WXT
|
||||
|
||||
1. Install the NPM package:
|
||||
```bash
|
||||
pnpm i @wxt-dev/analytics
|
||||
```
|
||||
2. Create an `analytics` instance:
|
||||
|
||||
```ts
|
||||
// utils/analytics.ts
|
||||
import { createAnalytics } from '@wxt-dev/analytics';
|
||||
|
||||
export const analytics = createAnalytics({
|
||||
providers: [
|
||||
// ...
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
3. Import your analytics module in the background to initialize the message listener:
|
||||
```ts
|
||||
// background.ts
|
||||
import './utils/analytics';
|
||||
```
|
||||
4. Then use your `analytics` instance to report events:
|
||||
|
||||
```ts
|
||||
import { analytics } from './utils/analytics';
|
||||
|
||||
await analytics.track('some-event');
|
||||
await analytics.page();
|
||||
await analytics.identify('some-user-id');
|
||||
analytics.autoTrack(document.body);
|
||||
```
|
||||
|
||||
## Providers
|
||||
|
||||
### Google Analytics 4 (Measurement Protocol)
|
||||
|
||||
The [Measurement Protocol](https://developers.google.com/analytics/devguides/collection/protocol/ga4) is an alternative to GTag for reporting events to Google Analytics for MV3 extensions.
|
||||
|
||||
> [Why use the Measurement Protocol instead of GTag?](https://developer.chrome.com/docs/extensions/how-to/integrate/google-analytics-4#measurement-protocol)
|
||||
|
||||
Follow [Google's documentation](https://developer.chrome.com/docs/extensions/how-to/integrate/google-analytics-4#setup-credentials) to obtain your credentials and put them in your `.env` file:
|
||||
|
||||
```dotenv
|
||||
WXT_GA_API_SECRET='...'
|
||||
```
|
||||
|
||||
Then add the `googleAnalytics4` provider to your `<srcDir>/app.config.ts` file:
|
||||
|
||||
```ts
|
||||
import { googleAnalytics4 } from '@wxt-dev/analytics/providers/google-analytics-4';
|
||||
|
||||
export default defineAppConfig({
|
||||
analytics: {
|
||||
providers: [
|
||||
googleAnalytics4({
|
||||
apiSecret: import.meta.env.WXT_GA_API_SECRET,
|
||||
measurementId: '...',
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Umami
|
||||
|
||||
[Umami](https://umami.is/) is a privacy-first, open source analytics platform.
|
||||
|
||||
In Umami's dashboard, create a new website. The website's name and domain can be anything. Obviously, an extension doesn't have a domain, so make one up if you don't have one.
|
||||
|
||||
After the website has been created, save the website ID and domain to your `.env` file:
|
||||
|
||||
```dotenv
|
||||
WXT_UMAMI_WEBSITE_ID='...'
|
||||
WXT_UMAMI_DOMAIN='...'
|
||||
```
|
||||
|
||||
Then add the `umami` provider to your `<srcDir>/app.config.ts` file:
|
||||
|
||||
```ts
|
||||
import { umami } from '@wxt-dev/analytics/providers/umami';
|
||||
|
||||
export default defineAppConfig({
|
||||
analytics: {
|
||||
providers: [
|
||||
umami({
|
||||
apiUrl: 'https://<your-umami-instance>/api',
|
||||
websiteId: import.meta.env.WXT_UMAMI_WEBSITE_ID,
|
||||
domain: import.meta.env.WXT_UMAMI_DOMAIN,
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Provider
|
||||
|
||||
If your analytics platform is not supported, you can provide an implementation of the `AnalyticsProvider` type in your `app.config.ts` instead:
|
||||
|
||||
```ts
|
||||
import { defineAnalyticsProvider } from '@wxt-dev/analytics/client';
|
||||
|
||||
interface CustomAnalyticsOptions {
|
||||
// ...
|
||||
}
|
||||
|
||||
const customAnalytics = defineAnalyticsProvider<CustomAnalyticsOptions>(
|
||||
(analytics, analyticsConfig, providerOptions) => {
|
||||
// ...
|
||||
},
|
||||
);
|
||||
|
||||
export default defineAppConfig({
|
||||
analytics: {
|
||||
providers: [
|
||||
customAnalytics({
|
||||
// ...
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Example `AnalyticsProvider` implementations can be found at [`./modules/analytics/providers`](https://github.com/wxt-dev/wxt/tree/main/packages/analytics/modules/analytics/providers).
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineAppConfig } from 'wxt/sandbox';
|
||||
import { googleAnalytics4 } from './modules/analytics/providers/google-analytics-4';
|
||||
import { umami } from './modules/analytics/providers/umami';
|
||||
|
||||
export default defineAppConfig({
|
||||
analytics: {
|
||||
debug: true,
|
||||
providers: [
|
||||
googleAnalytics4({
|
||||
apiSecret: '...',
|
||||
measurementId: '...',
|
||||
}),
|
||||
umami({
|
||||
apiUrl: 'https://umami.aklinker1.io/api',
|
||||
domain: 'analytics.wxt.dev',
|
||||
websiteId: '8f1c2aa4-fad3-406e-a5b2-33e8d4501716',
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Build module and plugins
|
||||
export default defineBuildConfig({
|
||||
rootDir: 'modules/analytics',
|
||||
outDir: resolve(__dirname, 'dist'),
|
||||
entries: [
|
||||
{ input: 'index.ts', name: 'module' },
|
||||
{ input: 'client.ts', name: 'index' },
|
||||
'background-plugin.ts',
|
||||
'types.ts',
|
||||
'providers/google-analytics-4.ts',
|
||||
'providers/umami.ts',
|
||||
],
|
||||
externals: ['#analytics'],
|
||||
replace: {
|
||||
'import.meta.env.NPM': 'true',
|
||||
},
|
||||
declaration: true,
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Popup</title>
|
||||
</head>
|
||||
<body>
|
||||
<label>
|
||||
<input id="enabledCheckbox" type="checkbox" />
|
||||
 Analytics enabled
|
||||
</label>
|
||||
<button id="button1">Button 1</button>
|
||||
<button class="cool-button">Button 2</button>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { analytics } from '#analytics';
|
||||
|
||||
declare const enabledCheckbox: HTMLInputElement;
|
||||
|
||||
analytics.autoTrack(document);
|
||||
|
||||
enabledCheckbox.oninput = () => {
|
||||
void analytics.setEnabled(enabledCheckbox.checked);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import '#analytics';
|
||||
|
||||
export default () => {};
|
||||
@@ -0,0 +1,288 @@
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import type {
|
||||
Analytics,
|
||||
AnalyticsConfig,
|
||||
AnalyticsPageViewEvent,
|
||||
AnalyticsStorageItem,
|
||||
AnalyticsTrackEvent,
|
||||
BaseAnalyticsEvent,
|
||||
AnalyticsEventMetadata,
|
||||
AnalyticsProvider,
|
||||
} from './types';
|
||||
|
||||
const ANALYTICS_PORT = '@wxt-dev/analytics';
|
||||
|
||||
export function createAnalytics(config?: AnalyticsConfig): Analytics {
|
||||
if (typeof chrome === 'undefined' || !chrome?.runtime?.id)
|
||||
throw Error(
|
||||
'Cannot use WXT analytics in contexts without access to the browser.runtime APIs',
|
||||
);
|
||||
if (config == null) {
|
||||
console.warn(
|
||||
"[@wxt-dev/analytics] Config not provided to createAnalytics. If you're using WXT, add the 'analytics' property to '<srcDir>/app.config.ts'.",
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: This only works for standard WXT extensions, add a more generic
|
||||
// background script detector that works with non-WXT projects.
|
||||
if (location.pathname === '/background.js')
|
||||
return createBackgroundAnalytics(config);
|
||||
|
||||
return createFrontendAnalytics();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an analytics client in the background responsible for uploading events to the server to avoid CORS errors.
|
||||
*/
|
||||
function createBackgroundAnalytics(
|
||||
config: AnalyticsConfig | undefined,
|
||||
): Analytics {
|
||||
// User properties storage
|
||||
const userIdStorage =
|
||||
config?.userId ?? defineStorageItem<string>('wxt-analytics:user-id');
|
||||
const userPropertiesStorage =
|
||||
config?.userProperties ??
|
||||
defineStorageItem<Record<string, string>>(
|
||||
'wxt-analytics:user-properties',
|
||||
{},
|
||||
);
|
||||
const enabled =
|
||||
config?.enabled ??
|
||||
defineStorageItem<boolean>('local:wxt-analytics:enabled', false);
|
||||
|
||||
// Cached values
|
||||
const platformInfo = chrome.runtime.getPlatformInfo();
|
||||
const userAgent = UAParser();
|
||||
let userId = Promise.resolve(userIdStorage.getValue()).then(
|
||||
(id) => id ?? globalThis.crypto.randomUUID(),
|
||||
);
|
||||
let userProperties = userPropertiesStorage.getValue();
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
|
||||
const getBackgroundMeta = () => ({
|
||||
timestamp: Date.now(),
|
||||
// Don't track sessions for the background, it can be running
|
||||
// indefinitely, and will inflate session duration stats.
|
||||
sessionId: undefined,
|
||||
language: navigator.language,
|
||||
referrer: undefined,
|
||||
screen: undefined,
|
||||
url: location.href,
|
||||
title: undefined,
|
||||
});
|
||||
|
||||
const getBaseEvent = async (
|
||||
meta: AnalyticsEventMetadata,
|
||||
): Promise<BaseAnalyticsEvent> => {
|
||||
const platform = await platformInfo;
|
||||
return {
|
||||
meta,
|
||||
user: {
|
||||
id: await userId,
|
||||
properties: {
|
||||
version: config?.version ?? manifest.version_name ?? manifest.version,
|
||||
wxtMode: import.meta.env.MODE,
|
||||
wxtBrowser: import.meta.env.BROWSER,
|
||||
arch: platform.arch,
|
||||
os: platform.os,
|
||||
browser: userAgent.browser.name,
|
||||
browserVersion: userAgent.browser.version,
|
||||
...(await userProperties),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const analytics = {
|
||||
identify: async (
|
||||
newUserId: string,
|
||||
newUserProperties: Record<string, string> = {},
|
||||
meta: AnalyticsEventMetadata = getBackgroundMeta(),
|
||||
) => {
|
||||
// Update in-memory cache for all providers
|
||||
userId = Promise.resolve(newUserId);
|
||||
userProperties = Promise.resolve(newUserProperties);
|
||||
// Persist user info to storage
|
||||
await Promise.all([
|
||||
userIdStorage.setValue?.(newUserId),
|
||||
userPropertiesStorage.setValue?.(newUserProperties),
|
||||
]);
|
||||
// Notify providers
|
||||
const event = await getBaseEvent(meta);
|
||||
if (config?.debug) console.debug('[@wxt-dev/analytics] identify', event);
|
||||
if (await enabled.getValue()) {
|
||||
await Promise.allSettled(
|
||||
providers.map((provider) => provider.identify(event)),
|
||||
);
|
||||
} else if (config?.debug) {
|
||||
console.debug(
|
||||
'[@wxt-dev/analytics] Analytics disabled, identify() not uploaded',
|
||||
);
|
||||
}
|
||||
},
|
||||
page: async (
|
||||
location: string,
|
||||
meta: AnalyticsEventMetadata = getBackgroundMeta(),
|
||||
) => {
|
||||
const baseEvent = await getBaseEvent(meta);
|
||||
const event: AnalyticsPageViewEvent = {
|
||||
...baseEvent,
|
||||
page: {
|
||||
url: meta?.url ?? globalThis.location?.href,
|
||||
location,
|
||||
title: meta?.title ?? globalThis.document?.title,
|
||||
},
|
||||
};
|
||||
if (config?.debug) console.debug('[@wxt-dev/analytics] page', event);
|
||||
if (await enabled.getValue()) {
|
||||
await Promise.allSettled(
|
||||
providers.map((provider) => provider.page(event)),
|
||||
);
|
||||
} else if (config?.debug) {
|
||||
console.debug(
|
||||
'[@wxt-dev/analytics] Analytics disabled, page() not uploaded',
|
||||
);
|
||||
}
|
||||
},
|
||||
track: async (
|
||||
eventName: string,
|
||||
eventProperties?: Record<string, string>,
|
||||
meta: AnalyticsEventMetadata = getBackgroundMeta(),
|
||||
) => {
|
||||
const baseEvent = await getBaseEvent(meta);
|
||||
const event: AnalyticsTrackEvent = {
|
||||
...baseEvent,
|
||||
event: { name: eventName, properties: eventProperties },
|
||||
};
|
||||
if (config?.debug) console.debug('[@wxt-dev/analytics] track', event);
|
||||
if (await enabled.getValue()) {
|
||||
await Promise.allSettled(
|
||||
providers.map((provider) => provider.track(event)),
|
||||
);
|
||||
} else if (config?.debug) {
|
||||
console.debug(
|
||||
'[@wxt-dev/analytics] Analytics disabled, track() not uploaded',
|
||||
);
|
||||
}
|
||||
},
|
||||
setEnabled: async (newEnabled) => {
|
||||
await enabled.setValue?.(newEnabled);
|
||||
},
|
||||
autoTrack: () => {
|
||||
// Noop, background doesn't have a UI
|
||||
return () => {};
|
||||
},
|
||||
} satisfies Analytics;
|
||||
|
||||
const providers =
|
||||
config?.providers?.map((provider) => provider(analytics, config)) ?? [];
|
||||
|
||||
// Listen for messages from the rest of the extension
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
if (port.name === ANALYTICS_PORT) {
|
||||
port.onMessage.addListener(({ fn, args }) => {
|
||||
// @ts-expect-error: Untyped fn key
|
||||
void analytics[fn]?.(...args);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return analytics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an analytics client for non-background contexts.
|
||||
*/
|
||||
function createFrontendAnalytics(): Analytics {
|
||||
const port = chrome.runtime.connect({ name: ANALYTICS_PORT });
|
||||
const sessionId = Date.now();
|
||||
const getFrontendMetadata = (): AnalyticsEventMetadata => ({
|
||||
sessionId,
|
||||
timestamp: Date.now(),
|
||||
language: navigator.language,
|
||||
referrer: globalThis.document?.referrer || undefined,
|
||||
screen: globalThis.window
|
||||
? `${globalThis.window.screen.width}x${globalThis.window.screen.height}`
|
||||
: undefined,
|
||||
url: location.href,
|
||||
title: document.title || undefined,
|
||||
});
|
||||
|
||||
const methodForwarder =
|
||||
(fn: string) =>
|
||||
(...args: any[]) => {
|
||||
port.postMessage({ fn, args: [...args, getFrontendMetadata()] });
|
||||
};
|
||||
|
||||
const analytics: Analytics = {
|
||||
identify: methodForwarder('identify'),
|
||||
page: methodForwarder('page'),
|
||||
track: methodForwarder('track'),
|
||||
setEnabled: methodForwarder('setEnabled'),
|
||||
autoTrack: (root) => {
|
||||
const onClick = (event: Event) => {
|
||||
const element = event.target as any;
|
||||
if (
|
||||
!element ||
|
||||
(!INTERACTIVE_TAGS.has(element.tagName) &&
|
||||
!INTERACTIVE_ROLES.has(element.getAttribute('role')))
|
||||
)
|
||||
return;
|
||||
|
||||
void analytics.track('click', {
|
||||
tagName: element.tagName?.toLowerCase(),
|
||||
id: element.id || undefined,
|
||||
className: element.className || undefined,
|
||||
textContent: element.textContent?.substring(0, 50) || undefined, // Limit text content length
|
||||
href: element.href,
|
||||
});
|
||||
};
|
||||
root.addEventListener('click', onClick, { capture: true, passive: true });
|
||||
return () => {
|
||||
root.removeEventListener('click', onClick);
|
||||
};
|
||||
},
|
||||
};
|
||||
return analytics;
|
||||
}
|
||||
|
||||
function defineStorageItem<T>(
|
||||
key: string,
|
||||
defaultValue?: NonNullable<T>,
|
||||
): AnalyticsStorageItem<T> {
|
||||
return {
|
||||
getValue: async () =>
|
||||
(await chrome.storage.local.get(key))[key] ?? defaultValue,
|
||||
setValue: (newValue) => chrome.storage.local.set({ [key]: newValue }),
|
||||
};
|
||||
}
|
||||
|
||||
const INTERACTIVE_TAGS = new Set([
|
||||
'A',
|
||||
'BUTTON',
|
||||
'INPUT',
|
||||
'SELECT',
|
||||
'TEXTAREA',
|
||||
]);
|
||||
const INTERACTIVE_ROLES = new Set([
|
||||
'button',
|
||||
'link',
|
||||
'checkbox',
|
||||
'menuitem',
|
||||
'tab',
|
||||
'radio',
|
||||
]);
|
||||
|
||||
export function defineAnalyticsProvider<T = never>(
|
||||
definition: (
|
||||
/** The analytics object. */
|
||||
analytics: Analytics,
|
||||
/** Config passed into the analytics module from `app.config.ts`. */
|
||||
config: AnalyticsConfig,
|
||||
/** Provider options */
|
||||
options: T,
|
||||
) => ReturnType<AnalyticsProvider>,
|
||||
): (options: T) => AnalyticsProvider {
|
||||
return (options) => (analytics, config) =>
|
||||
definition(analytics, config, options);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'wxt';
|
||||
import 'wxt/sandbox';
|
||||
import { addAlias, addWxtPlugin, defineWxtModule } from 'wxt/modules';
|
||||
import { relative, resolve } from 'node:path';
|
||||
import type { AnalyticsConfig } from './types';
|
||||
|
||||
declare module 'wxt/sandbox' {
|
||||
export interface WxtAppConfig {
|
||||
analytics: AnalyticsConfig;
|
||||
}
|
||||
}
|
||||
|
||||
export default defineWxtModule({
|
||||
name: 'analytics',
|
||||
imports: [{ name: 'analytics', from: '#analytics' }],
|
||||
setup(wxt) {
|
||||
// Paths
|
||||
const wxtAnalyticsFolder = resolve(wxt.config.wxtDir, 'analytics');
|
||||
const wxtAnalyticsIndex = resolve(wxtAnalyticsFolder, 'index.ts');
|
||||
const clientModuleId = import.meta.env.NPM
|
||||
? '@wxt-dev/analytics'
|
||||
: resolve(wxt.config.modulesDir, 'analytics/client');
|
||||
const pluginModuleId = import.meta.env.NPM
|
||||
? '@wxt-dev/analytics/background-plugin'
|
||||
: resolve(wxt.config.modulesDir, 'analytics/background-plugin');
|
||||
|
||||
// Add required permissions
|
||||
wxt.hook('build:manifestGenerated', (_, manifest) => {
|
||||
manifest.permissions ??= [];
|
||||
if (!manifest.permissions.includes('storage')) {
|
||||
manifest.permissions.push('storage');
|
||||
}
|
||||
});
|
||||
|
||||
// Generate #analytics module
|
||||
const wxtAnalyticsCode = [
|
||||
`import { createAnalytics } from '${
|
||||
import.meta.env.NPM
|
||||
? clientModuleId
|
||||
: relative(wxtAnalyticsFolder, clientModuleId)
|
||||
}';`,
|
||||
`import { useAppConfig } from 'wxt/client';`,
|
||||
``,
|
||||
`export const analytics = createAnalytics(useAppConfig().analytics);`,
|
||||
``,
|
||||
].join('\n');
|
||||
addAlias(wxt, '#analytics', wxtAnalyticsIndex);
|
||||
wxt.hook('prepare:types', async (_, entries) => {
|
||||
entries.push({
|
||||
path: wxtAnalyticsIndex,
|
||||
text: wxtAnalyticsCode,
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure there is a background entrypoint
|
||||
wxt.hook('entrypoints:resolved', (_, entrypoints) => {
|
||||
const hasBackground = entrypoints.find(
|
||||
(entry) => entry.type === 'background',
|
||||
);
|
||||
if (!hasBackground) {
|
||||
entrypoints.push({
|
||||
type: 'background',
|
||||
inputPath: 'virtual:user-background',
|
||||
name: 'background',
|
||||
options: {},
|
||||
outputDir: wxt.config.outDir,
|
||||
skipped: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure analytics is initialized in every context, mainly the background.
|
||||
// TODO: Once there's a way to filter which entrypoints a plugin is applied to, only apply this to the background
|
||||
addWxtPlugin(wxt, pluginModuleId);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { defineAnalyticsProvider } from '../client';
|
||||
import type { BaseAnalyticsEvent } from '../types';
|
||||
|
||||
const DEFAULT_ENGAGEMENT_TIME_IN_MSEC = 100;
|
||||
|
||||
export interface GoogleAnalytics4ProviderOptions {
|
||||
apiSecret: string;
|
||||
measurementId: string;
|
||||
}
|
||||
|
||||
export const googleAnalytics4 =
|
||||
defineAnalyticsProvider<GoogleAnalytics4ProviderOptions>(
|
||||
(_, config, options) => {
|
||||
const send = async (
|
||||
data: BaseAnalyticsEvent,
|
||||
eventName: string,
|
||||
eventProperties: Record<string, string | undefined> | undefined,
|
||||
): Promise<void> => {
|
||||
const url = new URL(
|
||||
config?.debug ? '/debug/mp/collect' : '/mp/collect',
|
||||
'https://www.google-analytics.com',
|
||||
);
|
||||
if (options.apiSecret)
|
||||
url.searchParams.set('api_secret', options.apiSecret);
|
||||
if (options.measurementId)
|
||||
url.searchParams.set('measurement_id', options.measurementId);
|
||||
|
||||
const userProperties = {
|
||||
language: data.meta.language,
|
||||
screen: data.meta.screen,
|
||||
...data.user.properties,
|
||||
};
|
||||
const mappedUserProperties = Object.fromEntries(
|
||||
Object.entries(userProperties).map(([name, value]) => [
|
||||
name,
|
||||
value == null ? undefined : { value },
|
||||
]),
|
||||
);
|
||||
|
||||
await fetch(url.href, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
client_id: data.user.id,
|
||||
consent: {
|
||||
ad_user_data: 'DENIED',
|
||||
ad_personalization: 'DENIED',
|
||||
},
|
||||
user_properties: mappedUserProperties,
|
||||
events: [
|
||||
{
|
||||
name: eventName,
|
||||
params: {
|
||||
session_id: data.meta.sessionId,
|
||||
engagement_time_msec: DEFAULT_ENGAGEMENT_TIME_IN_MSEC,
|
||||
...eventProperties,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
identify: () => Promise.resolve(), // No-op, user data uploaded in page/track
|
||||
page: (event) =>
|
||||
send(event, 'page_view', {
|
||||
page_title: event.page.title,
|
||||
page_location: event.page.location,
|
||||
}),
|
||||
track: (event) => send(event, event.event.name, event.event.properties),
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
import { defineAnalyticsProvider } from '../client';
|
||||
|
||||
export interface UmamiProviderOptions {
|
||||
apiUrl: string;
|
||||
websiteId: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export const umami = defineAnalyticsProvider<UmamiProviderOptions>(
|
||||
(_, config, options) => {
|
||||
const send = (payload: UmamiPayload) => {
|
||||
if (config.debug) {
|
||||
console.debug('[@wxt-dev/analytics] Sending event to Umami:', payload);
|
||||
}
|
||||
return fetch(`${options.apiUrl}/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ type: 'event', payload }),
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
identify: () => Promise.resolve(), // No-op, user data uploaded in page/track
|
||||
page: async (event) => {
|
||||
await send({
|
||||
name: 'page_view',
|
||||
website: options.websiteId,
|
||||
url: event.page.url,
|
||||
hostname: options.domain,
|
||||
language: event.meta.language ?? '',
|
||||
referrer: event.meta.referrer ?? '',
|
||||
screen: event.meta.screen ?? '',
|
||||
title: event.page.title ?? '<blank>',
|
||||
data: event.user.properties,
|
||||
});
|
||||
},
|
||||
track: async (event) => {
|
||||
await send({
|
||||
name: event.event.name,
|
||||
website: options.websiteId,
|
||||
url: event.meta.url ?? '/',
|
||||
title: '<blank>',
|
||||
hostname: options.domain,
|
||||
language: event.meta.language ?? '',
|
||||
referrer: event.meta.referrer ?? '',
|
||||
screen: event.meta.screen ?? '',
|
||||
data: {
|
||||
...event.event.properties,
|
||||
...event.user.properties,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/** @see https://umami.is/docs/api/sending-stats#post-/api/send */
|
||||
interface UmamiPayload {
|
||||
hostname?: string;
|
||||
language?: string;
|
||||
referrer?: string;
|
||||
screen?: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
website: string;
|
||||
name: string;
|
||||
data?: Record<string, string | undefined>;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
export interface Analytics {
|
||||
/** Report a page change */
|
||||
page: (url: string) => void;
|
||||
/** Report a custom event */
|
||||
track: (eventName: string, eventProperties?: Record<string, string>) => void;
|
||||
/** Save information about the user */
|
||||
identify: (userId: string, userProperties?: Record<string, string>) => void;
|
||||
/** Automatically setup and track user interactions, returning a function to remove any listeners that were setup. */
|
||||
autoTrack: (root: Document | ShadowRoot | Element) => () => void;
|
||||
/** Calls `config.enabled.setValue` */
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export interface AnalyticsConfig {
|
||||
/**
|
||||
* Array of providers to send analytics to.
|
||||
*/
|
||||
providers: AnalyticsProvider[];
|
||||
/**
|
||||
* Enable debug logs and other provider-specific debugging features.
|
||||
*/
|
||||
debug?: boolean;
|
||||
/**
|
||||
* Extension version, defaults to `browser.runtime.getManifest().version`.
|
||||
*/
|
||||
version?: string;
|
||||
/**
|
||||
* Configure how the enabled flag is persisted. Defaults to using `""` in local extension storage.
|
||||
*/
|
||||
enabled?: AnalyticsStorageItem<boolean>;
|
||||
/**
|
||||
* Configure how the user Id is persisted
|
||||
*/
|
||||
userId?: AnalyticsStorageItem<string>;
|
||||
/**
|
||||
* Configure how user properties are persisted
|
||||
*/
|
||||
userProperties?: AnalyticsStorageItem<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface AnalyticsStorageItem<T> {
|
||||
getValue: () => T | Promise<T>;
|
||||
setValue?: (newValue: T) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export type AnalyticsProvider = (
|
||||
analytics: Analytics,
|
||||
config: AnalyticsConfig,
|
||||
) => {
|
||||
/** Upload a page view event */
|
||||
page: (event: AnalyticsPageViewEvent) => Promise<void>;
|
||||
/** Upload a custom event */
|
||||
track: (event: AnalyticsTrackEvent) => Promise<void>;
|
||||
/** Upload information about the user */
|
||||
identify: (event: BaseAnalyticsEvent) => Promise<void>;
|
||||
};
|
||||
|
||||
export interface BaseAnalyticsEvent {
|
||||
meta: AnalyticsEventMetadata;
|
||||
user: {
|
||||
id: string;
|
||||
properties: Record<string, string | undefined>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnalyticsEventMetadata {
|
||||
/** Identifier of the session the event was fired from */
|
||||
sessionId: number | undefined;
|
||||
/** `Date.now()` of when the event was reported */
|
||||
timestamp: number;
|
||||
/** `"1920x1080"` */
|
||||
screen: string | undefined;
|
||||
/** `document.referrer` */
|
||||
referrer: string | undefined;
|
||||
/** `navigator.language` */
|
||||
language: string | undefined;
|
||||
/** `location.href` */
|
||||
url: string | undefined;
|
||||
/** `document.title` */
|
||||
title: string | undefined;
|
||||
}
|
||||
|
||||
export interface AnalyticsPageInfo {
|
||||
url: string;
|
||||
title: string | undefined;
|
||||
location: string | undefined;
|
||||
}
|
||||
|
||||
export interface AnalyticsPageViewEvent extends BaseAnalyticsEvent {
|
||||
page: AnalyticsPageInfo;
|
||||
}
|
||||
|
||||
export interface AnalyticsTrackEvent extends BaseAnalyticsEvent {
|
||||
event: {
|
||||
name: string;
|
||||
properties?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "@wxt-dev/analytics",
|
||||
"version": "0.4.0",
|
||||
"description": "Add analytics to your web extension",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/wxt-dev/wxt.git",
|
||||
"directory": "packages/analytics"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./module": {
|
||||
"types": "./dist/module.d.mts",
|
||||
"default": "./dist/module.mjs"
|
||||
},
|
||||
"./background-plugin": {
|
||||
"types": "./dist/background-plugin.d.mts",
|
||||
"default": "./dist/background-plugin.mjs"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.mts"
|
||||
},
|
||||
"./providers/google-analytics-4": {
|
||||
"types": "./dist/providers/google-analytics-4.d.mts",
|
||||
"default": "./dist/providers/google-analytics-4.mjs"
|
||||
},
|
||||
"./providers/umami": {
|
||||
"types": "./dist/providers/umami.d.mts",
|
||||
"default": "./dist/providers/umami.mjs"
|
||||
}
|
||||
},
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "buildc --deps-only -- wxt",
|
||||
"dev:build": "buildc --deps-only -- wxt build",
|
||||
"check": "pnpm build && check",
|
||||
"build": "buildc -- unbuild",
|
||||
"prepack": "pnpm -s build",
|
||||
"prepare": "buildc --deps-only -- wxt prepare"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"wxt": ">=0.19.23"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aklinker1/check": "^1.3.1",
|
||||
"@types/chrome": "^0.0.268",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"prettier": "^3.3.2",
|
||||
"publint": "^0.2.8",
|
||||
"typescript": "^5.5.2",
|
||||
"unbuild": "^2.0.0",
|
||||
"wxt": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"ua-parser-js": "^1.0.38"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": ["../../tsconfig.base.json", "./.wxt/tsconfig.json"],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"#analytics": ["./.wxt/analytics/index.ts"]
|
||||
},
|
||||
"types": ["chrome"]
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'wxt';
|
||||
|
||||
export default defineConfig({
|
||||
// Unimport doesn't look for imports in node_modules, so when developing a
|
||||
// WXT module, we need to disable this to simplify the build process
|
||||
imports: false,
|
||||
|
||||
manifest: {
|
||||
name: 'Analytics Demo',
|
||||
},
|
||||
});
|
||||
@@ -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:*"
|
||||
},
|
||||
|
||||
@@ -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:*"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## v1.1.3
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/module-react-v1.1.2...module-react-v1.1.3)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Use react 19 in `@wxt-dev/module-react` and `templates/react` ([#1285](https://github.com/wxt-dev/wxt/pull/1285))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
|
||||
## 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.3",
|
||||
"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.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aklinker1/check": "^1.4.5",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"publint": "^0.2.11",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"typescript": "^5.6.2",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"publint": "^0.2.12",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"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,33 @@
|
||||
# Changelog
|
||||
|
||||
## v2.0.2
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/module-svelte-v2.0.1...module-svelte-v2.0.2)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Svelte production errors and warnings on resolve conditions ([#1283](https://github.com/wxt-dev/wxt/pull/1283))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
|
||||
## 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)
|
||||
@@ -15,7 +43,9 @@
|
||||
|
||||
#### ⚠️ Breaking Changes
|
||||
|
||||
- ⚠️ Svelte 5 support ([#1104](https://github.com/wxt-dev/wxt/pull/1104))
|
||||
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
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"email": "aaronklinker1+wxt@gmail.com"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -50,8 +50,8 @@
|
||||
},
|
||||
"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: ['browser', mode],
|
||||
},
|
||||
}));
|
||||
|
||||
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"
|
||||
]
|
||||
}
|
||||
+4
-5
@@ -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 { MigrationError, 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
|
||||
@@ -223,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(),
|
||||
};
|
||||
@@ -239,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 });
|
||||
@@ -1163,7 +1162,7 @@ describe('Storage Utils', () => {
|
||||
|
||||
await item.removeValue();
|
||||
// Make sure it's actually blank before running the test
|
||||
expect(await browser.storage.local.get()).toEqual({});
|
||||
expect(await chrome.storage.local.get()).toEqual({});
|
||||
init.mockClear();
|
||||
|
||||
const [value1, value2] = await Promise.all([
|
||||
@@ -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);
|
||||
@@ -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) => {
|
||||
|
||||
@@ -18,19 +18,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@wxt-dev/i18n": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.269",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/chrome": "^0.0.280",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@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:*"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'uno.css';
|
||||
import './style.css';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['https://*.duckduckgo.com/*'],
|
||||
cssInjectionMode: 'manifest',
|
||||
|
||||
async main(ctx) {
|
||||
const dynamicUI = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
append: 'after',
|
||||
anchor: 'form[role=search]',
|
||||
onMount: (container) => {
|
||||
const app = document.createElement('div');
|
||||
container.id = 'automount-anchor';
|
||||
app.classList.add('m-4', 'text-center', 'text-red-500');
|
||||
app.textContent = i18n.t('prompt_for_name');
|
||||
container.append(app);
|
||||
return { container, app };
|
||||
},
|
||||
onRemove() {
|
||||
console.log('dynamicUI removed');
|
||||
},
|
||||
});
|
||||
|
||||
const autoMountUi = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
append: 'after',
|
||||
anchor: '#automount-anchor',
|
||||
onMount: (container) => {
|
||||
const app = document.createElement('div');
|
||||
app.id = 'automount-ui';
|
||||
app.classList.add('m-0', 'text-center', 'text-blue-500');
|
||||
app.textContent = `Hello, I'm automount UI.`;
|
||||
container.append(app);
|
||||
},
|
||||
onRemove() {
|
||||
console.log('autoMountUi removed');
|
||||
},
|
||||
});
|
||||
|
||||
autoMountUi.autoMount({
|
||||
onStop: () => {
|
||||
console.log('Auto mount stopped.');
|
||||
},
|
||||
});
|
||||
|
||||
const stopAutoMountButton = createIntegratedUi(ctx, {
|
||||
position: 'inline',
|
||||
append: 'last',
|
||||
anchor: 'form[role=search]',
|
||||
onMount: (container) => {
|
||||
const app = document.createElement('button');
|
||||
container.classList.add('flex', 'flex-justify-center');
|
||||
app.classList.add('mt-4', 'p-2');
|
||||
app.textContent = 'Stop auto-mount';
|
||||
app.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
autoMountUi.remove();
|
||||
};
|
||||
container.append(app);
|
||||
return { container, app };
|
||||
},
|
||||
});
|
||||
|
||||
stopAutoMountButton.mount();
|
||||
|
||||
setInterval(() => {
|
||||
if (dynamicUI.mounted) {
|
||||
dynamicUI.remove();
|
||||
} else {
|
||||
dynamicUI.mount();
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
@@ -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,202 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.23
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.22...wxt-v0.19.23)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- **hooks:** Move `entrypoints:resolved` before debug logs and add `entrypoints:found` ([#1292](https://github.com/wxt-dev/wxt/pull/1292))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Allow runtime registered content scripts to not have `matches` ([#1306](https://github.com/wxt-dev/wxt/pull/1306))
|
||||
- Properly close readline instance on close ([#1278](https://github.com/wxt-dev/wxt/pull/1278))
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- Sync READMEs ([27298b7](https://github.com/wxt-dev/wxt/commit/27298b7))
|
||||
|
||||
### 🏡 Chore
|
||||
|
||||
- Simplify keyboard shortcuts ([#1284](https://github.com/wxt-dev/wxt/pull/1284))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
- Abhigyan Trips ([@abhigyantrips](http://github.com/abhigyantrips))
|
||||
|
||||
## v0.19.22
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.21...wxt-v0.19.22)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Exclude entire `import.meta.env` object from content script output ([#1267](https://github.com/wxt-dev/wxt/pull/1267))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
|
||||
## v0.19.21
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.20...wxt-v0.19.21)
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Exclude skipped entrypoints from manifest ([#1265](https://github.com/wxt-dev/wxt/pull/1265))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
|
||||
## v0.19.20
|
||||
|
||||
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.19...wxt-v0.19.20)
|
||||
|
||||
### 🚀 Enhancements
|
||||
|
||||
- `autoMount` content script UIs ([#1210](https://github.com/wxt-dev/wxt/pull/1210))
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- Only show reopen browser shortcut when it can be done ([#1263](https://github.com/wxt-dev/wxt/pull/1263))
|
||||
- Import entrypoint improvements ([#1207](https://github.com/wxt-dev/wxt/pull/1207))
|
||||
|
||||
### ❤️ Contributors
|
||||
|
||||
- Aaron ([@aklinker1](http://github.com/aklinker1))
|
||||
- 1natsu ([@1natsu172](http://github.com/1natsu172))
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -76,6 +76,12 @@ Or see the [installation guide](https://wxt.dev/guide/installation.html) to get
|
||||
- 📏 Bundle analysis
|
||||
- ⬇️ Download and bundle remote URL imports
|
||||
|
||||
## Sponsors
|
||||
|
||||
WXT is a [MIT-licensed](https://github.com/wxt-dev/wxt/blob/main/LICENSE) open source project with its ongoing development made possible entirely by the support of these awesome backers. If you'd like to join them, please consider [sponsoring WXT's development](https://github.com/sponsors/wxt-dev).
|
||||
|
||||
<a href="https://github.com/sponsors/wxt-dev"><img alt="WXT Sponsors" src="https://raw.githubusercontent.com/wxt-dev/static/refs/heads/main/sponsorkit/sponsors.svg"></a>
|
||||
|
||||
## Contributors
|
||||
|
||||
<!-- automd:contributors author="aklinker1" license="MIT" github="wxt-dev/wxt" -->
|
||||
|
||||
@@ -152,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,12 +4,14 @@ 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(),
|
||||
'build:done': vi.fn(),
|
||||
'build:manifestGenerated': vi.fn(),
|
||||
'build:publicAssets': vi.fn(),
|
||||
'entrypoints:found': vi.fn(),
|
||||
'entrypoints:resolved': vi.fn(),
|
||||
'entrypoints:grouped': vi.fn(),
|
||||
'vite:build:extendConfig': vi.fn(),
|
||||
@@ -20,6 +22,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,12 +54,14 @@ describe('Hooks', () => {
|
||||
|
||||
expectHooksToBeCalled({
|
||||
ready: true,
|
||||
'config:resolved': true,
|
||||
'prepare:types': true,
|
||||
'prepare:publicPaths': true,
|
||||
'build:before': false,
|
||||
'build:done': false,
|
||||
'build:publicAssets': false,
|
||||
'build:manifestGenerated': false,
|
||||
'entrypoints:found': true,
|
||||
'entrypoints:grouped': false,
|
||||
'entrypoints:resolved': true,
|
||||
'vite:build:extendConfig': false,
|
||||
@@ -65,6 +72,9 @@ describe('Hooks', () => {
|
||||
'zip:sources:start': false,
|
||||
'zip:sources:done': false,
|
||||
'zip:done': false,
|
||||
'server:created': false,
|
||||
'server:started': false,
|
||||
'server:closed': false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,12 +86,14 @@ describe('Hooks', () => {
|
||||
|
||||
expectHooksToBeCalled({
|
||||
ready: true,
|
||||
'config:resolved': true,
|
||||
'prepare:types': true,
|
||||
'prepare:publicPaths': true,
|
||||
'build:before': true,
|
||||
'build:done': true,
|
||||
'build:publicAssets': true,
|
||||
'build:manifestGenerated': true,
|
||||
'entrypoints:found': true,
|
||||
'entrypoints:grouped': true,
|
||||
'entrypoints:resolved': true,
|
||||
'vite:build:extendConfig': 1,
|
||||
@@ -92,6 +104,9 @@ describe('Hooks', () => {
|
||||
'zip:sources:start': false,
|
||||
'zip:sources:done': false,
|
||||
'zip:done': false,
|
||||
'server:created': false,
|
||||
'server:started': false,
|
||||
'server:closed': false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,12 +118,14 @@ describe('Hooks', () => {
|
||||
|
||||
expectHooksToBeCalled({
|
||||
ready: true,
|
||||
'config:resolved': true,
|
||||
'prepare:types': true,
|
||||
'prepare:publicPaths': true,
|
||||
'build:before': true,
|
||||
'build:done': true,
|
||||
'build:publicAssets': true,
|
||||
'build:manifestGenerated': true,
|
||||
'entrypoints:found': true,
|
||||
'entrypoints:grouped': true,
|
||||
'entrypoints:resolved': true,
|
||||
'vite:build:extendConfig': 1,
|
||||
@@ -119,6 +136,9 @@ describe('Hooks', () => {
|
||||
'zip:sources:start': false,
|
||||
'zip:sources:done': false,
|
||||
'zip:done': true,
|
||||
'server:created': false,
|
||||
'server:started': false,
|
||||
'server:closed': false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,14 +150,16 @@ describe('Hooks', () => {
|
||||
|
||||
expectHooksToBeCalled({
|
||||
ready: true,
|
||||
'config:resolved': true,
|
||||
'prepare:types': true,
|
||||
'prepare:publicPaths': true,
|
||||
'build:before': true,
|
||||
'build:done': true,
|
||||
'build:publicAssets': true,
|
||||
'build:manifestGenerated': true,
|
||||
'entrypoints:found': 2,
|
||||
'entrypoints:grouped': true,
|
||||
'entrypoints:resolved': true,
|
||||
'entrypoints:resolved': 2,
|
||||
'vite:build:extendConfig': 1,
|
||||
'vite:devServer:extendConfig': false,
|
||||
'zip:start': true,
|
||||
@@ -146,6 +168,9 @@ describe('Hooks', () => {
|
||||
'zip:sources:start': true,
|
||||
'zip:sources:done': true,
|
||||
'zip:done': true,
|
||||
'server:created': false,
|
||||
'server:started': false,
|
||||
'server:closed': false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,16 +184,19 @@ 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,
|
||||
'build:done': true,
|
||||
'build:publicAssets': true,
|
||||
'build:manifestGenerated': true,
|
||||
'entrypoints:found': true,
|
||||
'entrypoints:grouped': true,
|
||||
'entrypoints:resolved': true,
|
||||
'vite:build:extendConfig': 2,
|
||||
@@ -179,6 +207,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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+19
-18
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wxt",
|
||||
"type": "module",
|
||||
"version": "0.19.13",
|
||||
"version": "0.19.23",
|
||||
"description": "Next gen framework for developing web extensions",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -81,26 +81,27 @@
|
||||
"prepack": "pnpm build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@1natsu/wait-element": "^4.1.2",
|
||||
"@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 +111,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;
|
||||
}
|
||||
|
||||
@@ -15,12 +15,5 @@ export class WxtLocationChangeEvent extends Event {
|
||||
* Returns an event name unique to the extension and content script that's running.
|
||||
*/
|
||||
export function getUniqueEventName(eventName: string): string {
|
||||
// During the build process, import.meta.env is not defined when importing
|
||||
// entrypoints to get their metadata.
|
||||
const entrypointName =
|
||||
typeof import.meta.env === 'undefined'
|
||||
? 'build'
|
||||
: import.meta.env.ENTRYPOINT;
|
||||
|
||||
return `${browser?.runtime?.id}:${entrypointName}:${eventName}`;
|
||||
return `${browser?.runtime?.id}:${import.meta.env.ENTRYPOINT}:${eventName}`;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { describe, it, beforeEach, vi, expect } from 'vitest';
|
||||
import { createIntegratedUi, createIframeUi, createShadowRootUi } from '..';
|
||||
import { describe, it, beforeEach, afterEach, vi, expect } from 'vitest';
|
||||
import {
|
||||
createIntegratedUi,
|
||||
createIframeUi,
|
||||
createShadowRootUi,
|
||||
ContentScriptUi,
|
||||
} from '..';
|
||||
import { ContentScriptContext } from '../../content-script-context';
|
||||
|
||||
/**
|
||||
* Util for floating promise.
|
||||
*/
|
||||
async function runMicrotasks() {
|
||||
return await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function appendTestApp(container: HTMLElement) {
|
||||
container.innerHTML = '<app>Hello world</app>';
|
||||
}
|
||||
|
||||
function appendTestElement({
|
||||
tagName = 'div',
|
||||
id,
|
||||
}: {
|
||||
tagName?: string;
|
||||
id: string;
|
||||
}) {
|
||||
const parent = document.querySelector('#parent');
|
||||
if (!parent) {
|
||||
throw Error(
|
||||
'Parent element not found. Please check the testing environment DOM',
|
||||
);
|
||||
}
|
||||
const element = document.createElement(tagName);
|
||||
element.id = id;
|
||||
parent.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
const fetch = vi.fn();
|
||||
|
||||
describe('Content Script UIs', () => {
|
||||
@@ -480,4 +511,223 @@ describe('Content Script UIs', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Need call runMicrotasks after floating-promise and append/remove dom
|
||||
*/
|
||||
describe('auto mount', () => {
|
||||
const DYNAMIC_CHILD_ID = 'dynamic-child';
|
||||
let ui: ContentScriptUi<any>;
|
||||
beforeEach(async () => {
|
||||
ui?.remove();
|
||||
await runMicrotasks();
|
||||
});
|
||||
afterEach(async () => {
|
||||
ui?.remove();
|
||||
await runMicrotasks();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{
|
||||
name: 'integrated',
|
||||
createUiFunction: createIntegratedUi,
|
||||
uiSelector: 'div[data-wxt-integrated]',
|
||||
},
|
||||
{
|
||||
name: 'iframe',
|
||||
createUiFunction: createIframeUi,
|
||||
uiSelector: 'div[data-wxt-iframe]',
|
||||
},
|
||||
{
|
||||
name: 'shadow-root',
|
||||
createUiFunction: createShadowRootUi,
|
||||
uiSelector: 'test-component[data-wxt-shadow-root]',
|
||||
},
|
||||
] as const)(
|
||||
'built-in UI type: $name',
|
||||
({ name, createUiFunction, uiSelector }) => {
|
||||
it('should mount when an anchor is dynamically added and unmount when an anchor is removed', async () => {
|
||||
const onMount = vi.fn(appendTestApp);
|
||||
const onRemove = vi.fn();
|
||||
ui = await createUiFunction(ctx, {
|
||||
position: 'inline',
|
||||
onMount,
|
||||
onRemove,
|
||||
anchor: `#parent > #${DYNAMIC_CHILD_ID}`,
|
||||
page: name === 'iframe' ? '/page.html' : undefined,
|
||||
name: 'test-component',
|
||||
});
|
||||
let dynamicEl;
|
||||
ui.autoMount();
|
||||
await runMicrotasks();
|
||||
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await expect
|
||||
.poll(() => document.querySelector(uiSelector))
|
||||
.toBeNull();
|
||||
|
||||
dynamicEl = appendTestElement({ id: DYNAMIC_CHILD_ID });
|
||||
await runMicrotasks();
|
||||
await expect
|
||||
.poll(() => document.querySelector(uiSelector))
|
||||
.not.toBeNull();
|
||||
|
||||
dynamicEl.remove();
|
||||
await runMicrotasks();
|
||||
}
|
||||
|
||||
expect(onMount).toHaveBeenCalledTimes(3);
|
||||
expect(onRemove).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
describe('options', () => {
|
||||
it('should auto-mount only once mount and remove when the `once` option is true', async () => {
|
||||
const onMount = vi.fn(appendTestApp);
|
||||
const onRemove = vi.fn();
|
||||
ui = await createUiFunction(ctx, {
|
||||
position: 'inline',
|
||||
onMount,
|
||||
onRemove,
|
||||
anchor: `#parent > #${DYNAMIC_CHILD_ID}`,
|
||||
page: name === 'iframe' ? '/page.html' : undefined,
|
||||
name: 'test-component',
|
||||
});
|
||||
let dynamicEl;
|
||||
ui.autoMount({ once: true });
|
||||
await runMicrotasks();
|
||||
await expect
|
||||
.poll(() => document.querySelector(uiSelector))
|
||||
.toBeNull();
|
||||
|
||||
dynamicEl = appendTestElement({ id: DYNAMIC_CHILD_ID });
|
||||
await runMicrotasks();
|
||||
await expect
|
||||
.poll(() => document.querySelector(uiSelector))
|
||||
.not.toBeNull();
|
||||
|
||||
dynamicEl.remove();
|
||||
await runMicrotasks();
|
||||
expect(onMount).toHaveBeenCalledTimes(1);
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
|
||||
// re-append after once cycle
|
||||
dynamicEl = appendTestElement({ id: DYNAMIC_CHILD_ID });
|
||||
await runMicrotasks();
|
||||
|
||||
// expect stop automount
|
||||
await expect
|
||||
.poll(() => document.querySelector(uiSelector))
|
||||
.toBeNull();
|
||||
expect(onMount).toHaveBeenCalledTimes(1);
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid anchors', () => {
|
||||
it('should throw when anchor is set as type Element', async () => {
|
||||
ui = await createUiFunction(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
anchor: document.documentElement,
|
||||
page: name === 'iframe' ? '/page.html' : undefined,
|
||||
name: 'test-component',
|
||||
});
|
||||
expect(() => ui.autoMount()).toThrowError(
|
||||
'autoMount and Element anchor option cannot be combined. Avoid passing `Element` directly or `() => Element` to the anchor.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when anchor is set as type `() => Element`', async () => {
|
||||
ui = await createUiFunction(ctx, {
|
||||
position: 'inline',
|
||||
onMount: appendTestApp,
|
||||
anchor: () => document.documentElement,
|
||||
page: name === 'iframe' ? '/page.html' : undefined,
|
||||
name: 'test-component',
|
||||
});
|
||||
expect(() => ui.autoMount()).toThrowError(
|
||||
'autoMount and Element anchor option cannot be combined. Avoid passing `Element` directly or `() => Element` to the anchor.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StopAutoMount', () => {
|
||||
it('should stop auto-mounting and remove ui when `ui.remove` is called', async () => {
|
||||
const onMount = vi.fn(appendTestApp);
|
||||
const onRemove = vi.fn();
|
||||
ui = await createUiFunction(ctx, {
|
||||
position: 'inline',
|
||||
onMount,
|
||||
onRemove,
|
||||
anchor: `#parent > #${DYNAMIC_CHILD_ID}`,
|
||||
page: name === 'iframe' ? '/page.html' : undefined,
|
||||
name: 'test-component',
|
||||
});
|
||||
let dynamicEl;
|
||||
ui.autoMount();
|
||||
await runMicrotasks();
|
||||
|
||||
dynamicEl = appendTestElement({ id: DYNAMIC_CHILD_ID });
|
||||
await runMicrotasks();
|
||||
await expect
|
||||
.poll(() => document.querySelector(uiSelector))
|
||||
.not.toBeNull();
|
||||
|
||||
dynamicEl.remove();
|
||||
await runMicrotasks();
|
||||
expect(onMount).toHaveBeenCalledTimes(1);
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
|
||||
ui.remove();
|
||||
|
||||
dynamicEl = appendTestElement({ id: DYNAMIC_CHILD_ID });
|
||||
dynamicEl.remove();
|
||||
await runMicrotasks();
|
||||
expect(onMount).toHaveBeenCalledTimes(1);
|
||||
expect(onRemove).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should call internal StopAutoMount when `ui.remove` is called', async () => {
|
||||
const onMount = vi.fn(appendTestApp);
|
||||
const onRemove = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
ui = await createUiFunction(ctx, {
|
||||
position: 'inline',
|
||||
onMount,
|
||||
onRemove,
|
||||
anchor: `#parent > #${DYNAMIC_CHILD_ID}`,
|
||||
page: name === 'iframe' ? '/page.html' : undefined,
|
||||
name: 'test-component',
|
||||
});
|
||||
ui.autoMount({ onStop });
|
||||
ui.remove();
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should allow calling automount again after internal StopAutoMount is called', async () => {
|
||||
const onMount = vi.fn(appendTestApp);
|
||||
ui = await createUiFunction(ctx, {
|
||||
position: 'inline',
|
||||
onMount,
|
||||
anchor: `#parent > #${DYNAMIC_CHILD_ID}`,
|
||||
page: name === 'iframe' ? '/page.html' : undefined,
|
||||
name: 'test-component',
|
||||
});
|
||||
const onStop = vi.fn();
|
||||
ui.autoMount({ onStop });
|
||||
ui.autoMount({ onStop });
|
||||
|
||||
ui.remove();
|
||||
expect(onStop).toBeCalledTimes(1);
|
||||
|
||||
ui.autoMount({ onStop });
|
||||
|
||||
ui.remove();
|
||||
expect(onStop).toBeCalledTimes(2);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import { waitElement } from '@1natsu/wait-element';
|
||||
import {
|
||||
isExist as mountDetector,
|
||||
isNotExist as removeDetector,
|
||||
} from '@1natsu/wait-element/detectors';
|
||||
import { ContentScriptContext } from '..';
|
||||
import {
|
||||
AutoMount,
|
||||
AutoMountOptions,
|
||||
BaseMountFunctions,
|
||||
ContentScriptAnchoredOptions,
|
||||
ContentScriptPositioningOptions,
|
||||
ContentScriptUiOptions,
|
||||
IframeContentScriptUi,
|
||||
IframeContentScriptUiOptions,
|
||||
IntegratedContentScriptUi,
|
||||
IntegratedContentScriptUiOptions,
|
||||
MountFunctions,
|
||||
ShadowRootContentScriptUi,
|
||||
ShadowRootContentScriptUiOptions,
|
||||
} from './types';
|
||||
@@ -34,10 +44,19 @@ export function createIntegratedUi<TMounted>(
|
||||
};
|
||||
const remove = () => {
|
||||
options.onRemove?.(mounted);
|
||||
wrapper.replaceChildren();
|
||||
wrapper.remove();
|
||||
mounted = undefined;
|
||||
};
|
||||
|
||||
const mountFunctions = createMountFunctions(
|
||||
{
|
||||
mount,
|
||||
remove,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
ctx.onInvalidated(remove);
|
||||
|
||||
return {
|
||||
@@ -45,8 +64,7 @@ export function createIntegratedUi<TMounted>(
|
||||
return mounted;
|
||||
},
|
||||
wrapper,
|
||||
mount,
|
||||
remove,
|
||||
...mountFunctions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +96,14 @@ export function createIframeUi<TMounted>(
|
||||
mounted = undefined;
|
||||
};
|
||||
|
||||
const mountFunctions = createMountFunctions(
|
||||
{
|
||||
mount,
|
||||
remove,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
ctx.onInvalidated(remove);
|
||||
|
||||
return {
|
||||
@@ -86,8 +112,7 @@ export function createIframeUi<TMounted>(
|
||||
},
|
||||
iframe,
|
||||
wrapper,
|
||||
mount,
|
||||
remove,
|
||||
...mountFunctions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,7 +161,7 @@ export async function createShadowRootUi<TMounted>(
|
||||
const remove = () => {
|
||||
// Cleanup mounted state
|
||||
options.onRemove?.(mounted);
|
||||
// Detatch shadow root from DOM
|
||||
// Detach shadow root from DOM
|
||||
shadowHost.remove();
|
||||
// Remove children from uiContainer
|
||||
while (uiContainer.lastChild)
|
||||
@@ -145,14 +170,21 @@ export async function createShadowRootUi<TMounted>(
|
||||
mounted = undefined;
|
||||
};
|
||||
|
||||
const mountFunctions = createMountFunctions(
|
||||
{
|
||||
mount,
|
||||
remove,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
ctx.onInvalidated(remove);
|
||||
|
||||
return {
|
||||
shadow,
|
||||
shadowHost,
|
||||
uiContainer,
|
||||
mount,
|
||||
remove,
|
||||
...mountFunctions,
|
||||
get mounted() {
|
||||
return mounted;
|
||||
},
|
||||
@@ -255,6 +287,107 @@ function mountUi(
|
||||
}
|
||||
}
|
||||
|
||||
function createMountFunctions<TMounted>(
|
||||
baseFunctions: BaseMountFunctions,
|
||||
options: ContentScriptUiOptions<TMounted>,
|
||||
): MountFunctions {
|
||||
let autoMountInstance: AutoMount | undefined = undefined;
|
||||
|
||||
const stopAutoMount = () => {
|
||||
autoMountInstance?.stopAutoMount();
|
||||
autoMountInstance = undefined;
|
||||
};
|
||||
|
||||
const mount = () => {
|
||||
baseFunctions.mount();
|
||||
};
|
||||
|
||||
const unmount = baseFunctions.remove;
|
||||
|
||||
const remove = () => {
|
||||
stopAutoMount();
|
||||
baseFunctions.remove();
|
||||
};
|
||||
|
||||
const autoMount = (autoMountOptions?: AutoMountOptions) => {
|
||||
if (autoMountInstance) {
|
||||
logger.warn('autoMount is already set.');
|
||||
}
|
||||
autoMountInstance = autoMountUi(
|
||||
{ mount, unmount, stopAutoMount },
|
||||
{
|
||||
...options,
|
||||
...autoMountOptions,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
mount,
|
||||
remove,
|
||||
autoMount,
|
||||
};
|
||||
}
|
||||
|
||||
function autoMountUi(
|
||||
uiCallbacks: {
|
||||
mount: () => void;
|
||||
unmount: () => void;
|
||||
stopAutoMount: () => void;
|
||||
},
|
||||
options: ContentScriptAnchoredOptions & AutoMountOptions,
|
||||
): AutoMount {
|
||||
const abortController = new AbortController();
|
||||
const EXPLICIT_STOP_REASON = 'explicit_stop_auto_mount';
|
||||
const _stopAutoMount = () => {
|
||||
abortController.abort(EXPLICIT_STOP_REASON);
|
||||
options.onStop?.();
|
||||
};
|
||||
|
||||
let resolvedAnchor =
|
||||
typeof options.anchor === 'function' ? options.anchor() : options.anchor;
|
||||
if (resolvedAnchor instanceof Element) {
|
||||
throw Error(
|
||||
'autoMount and Element anchor option cannot be combined. Avoid passing `Element` directly or `() => Element` to the anchor.',
|
||||
);
|
||||
}
|
||||
|
||||
async function observeElement(selector: string | null | undefined) {
|
||||
let isAnchorExist = !!getAnchor(options);
|
||||
|
||||
while (!abortController.signal.aborted) {
|
||||
try {
|
||||
const changedAnchor = await waitElement(selector ?? 'body', {
|
||||
customMatcher: () => getAnchor(options) ?? null,
|
||||
detector: isAnchorExist ? removeDetector : mountDetector,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
isAnchorExist = !!changedAnchor;
|
||||
if (isAnchorExist) {
|
||||
uiCallbacks.mount();
|
||||
} else {
|
||||
uiCallbacks.unmount();
|
||||
if (options.once) {
|
||||
uiCallbacks.stopAutoMount();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
abortController.signal.aborted &&
|
||||
abortController.signal.reason === EXPLICIT_STOP_REASON
|
||||
) {
|
||||
break;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
observeElement(resolvedAnchor);
|
||||
|
||||
return { stopAutoMount: _stopAutoMount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the CSS for the current entrypoint.
|
||||
*/
|
||||
|
||||
@@ -36,18 +36,7 @@ export interface ShadowRootContentScriptUi<TMounted>
|
||||
shadow: ShadowRoot;
|
||||
}
|
||||
|
||||
export interface ContentScriptUi<TMounted> {
|
||||
/**
|
||||
* Function that mounts or remounts the UI on the page.
|
||||
*/
|
||||
mount: () => void;
|
||||
/**
|
||||
* Function that removes the UI from the webpage.
|
||||
*/
|
||||
remove: () => void;
|
||||
/**>
|
||||
* Custom data returned from the `options.mount` function.
|
||||
*/
|
||||
export interface ContentScriptUi<TMounted> extends MountFunctions {
|
||||
mounted: TMounted | undefined;
|
||||
}
|
||||
|
||||
@@ -216,3 +205,40 @@ export interface ContentScriptAnchoredOptions {
|
||||
*/
|
||||
append?: ContentScriptAppendMode | ((anchor: Element, ui: Element) => void);
|
||||
}
|
||||
|
||||
export interface BaseMountFunctions {
|
||||
/**
|
||||
* Function that mounts or remounts the UI on the page.
|
||||
*/
|
||||
mount: () => void;
|
||||
|
||||
/**
|
||||
* Function that removes the UI from the webpage.
|
||||
*/
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
export interface MountFunctions extends BaseMountFunctions {
|
||||
/**
|
||||
* Call `ui.autoMount()` to automatically mount and remove the UI as the anchor is dynamically added/removed by the webpage.
|
||||
*/
|
||||
autoMount: (options?: AutoMountOptions) => void;
|
||||
}
|
||||
|
||||
export type AutoMountOptions = {
|
||||
/**
|
||||
* When true, only mount and unmount a UI once.
|
||||
*/
|
||||
once?: boolean;
|
||||
/**
|
||||
* The callback triggered when `StopAutoMount` is called.
|
||||
*/
|
||||
onStop?: () => void;
|
||||
};
|
||||
export type StopAutoMount = () => void;
|
||||
export interface AutoMount {
|
||||
/**
|
||||
* Stop watching the anchor element for changes, but keep the UI mounted.
|
||||
*/
|
||||
stopAutoMount: StopAutoMount;
|
||||
}
|
||||
|
||||
@@ -24,11 +24,13 @@ import { importEntrypointFile } from '../../utils/building';
|
||||
import { ViteNodeServer } from 'vite-node/server';
|
||||
import { ViteNodeRunner } from 'vite-node/client';
|
||||
import { installSourcemapsSupport } from 'vite-node/source-map';
|
||||
import { createExtensionEnvironment } from '../../utils/environments';
|
||||
import { relative } from 'node:path';
|
||||
|
||||
export async function createViteBuilder(
|
||||
wxtConfig: ResolvedConfig,
|
||||
hooks: Hookable<WxtHooks>,
|
||||
server?: WxtDevServer,
|
||||
getWxtDevServer?: () => WxtDevServer | undefined,
|
||||
): Promise<WxtBuilder> {
|
||||
const vite = await import('vite');
|
||||
|
||||
@@ -65,6 +67,8 @@ export async function createViteBuilder(
|
||||
ignored: [`${wxtConfig.outBaseDir}/**`, `${wxtConfig.wxtDir}/**`],
|
||||
};
|
||||
|
||||
const server = getWxtDevServer?.();
|
||||
|
||||
config.plugins ??= [];
|
||||
config.plugins.push(
|
||||
wxtPlugins.download(wxtConfig),
|
||||
@@ -193,7 +197,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 {
|
||||
@@ -218,55 +222,104 @@ export async function createViteBuilder(
|
||||
};
|
||||
};
|
||||
|
||||
const createViteNodeImporter = async (paths: string[]) => {
|
||||
const baseConfig = await getBaseConfig({
|
||||
excludeAnalysisPlugin: true,
|
||||
});
|
||||
// Disable dep optimization, as recommended by vite-node's README
|
||||
baseConfig.optimizeDeps ??= {};
|
||||
baseConfig.optimizeDeps.noDiscovery = true;
|
||||
baseConfig.optimizeDeps.include = [];
|
||||
const envConfig: vite.InlineConfig = {
|
||||
plugins: paths.map((path) =>
|
||||
wxtPlugins.removeEntrypointMainFunction(wxtConfig, path),
|
||||
),
|
||||
};
|
||||
const config = vite.mergeConfig(baseConfig, envConfig);
|
||||
const server = await vite.createServer(config);
|
||||
await server.pluginContainer.buildStart({});
|
||||
const node = new ViteNodeServer(
|
||||
// @ts-ignore: Some weird type error...
|
||||
server,
|
||||
);
|
||||
installSourcemapsSupport({
|
||||
getSourceMap: (source) => node.getSourceMap(source),
|
||||
});
|
||||
const runner = new ViteNodeRunner({
|
||||
root: server.config.root,
|
||||
base: server.config.base,
|
||||
// when having the server and runner in a different context,
|
||||
// you will need to handle the communication between them
|
||||
// and pass to this function
|
||||
fetchModule(id) {
|
||||
return node.fetchModule(id);
|
||||
},
|
||||
resolveId(id, importer) {
|
||||
return node.resolveId(id, importer);
|
||||
},
|
||||
});
|
||||
return { runner, server };
|
||||
};
|
||||
|
||||
const requireDefaultExport = (path: string, mod: any) => {
|
||||
const relativePath = relative(wxtConfig.root, path);
|
||||
if (mod?.default == null) {
|
||||
const defineFn = relativePath.includes('.content')
|
||||
? 'defineContentScript'
|
||||
: relativePath.includes('background')
|
||||
? 'defineBackground'
|
||||
: 'defineUnlistedScript';
|
||||
|
||||
throw Error(
|
||||
`${relativePath}: Default export not found, did you forget to call "export default ${defineFn}(...)"?`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'Vite',
|
||||
version: vite.version,
|
||||
async importEntrypoint(path) {
|
||||
const env = createExtensionEnvironment();
|
||||
switch (wxtConfig.entrypointLoader) {
|
||||
default:
|
||||
case 'jiti': {
|
||||
return await importEntrypointFile(path);
|
||||
return await env.run(() => importEntrypointFile(path));
|
||||
}
|
||||
case 'vite-node': {
|
||||
const baseConfig = await getBaseConfig({
|
||||
excludeAnalysisPlugin: true,
|
||||
});
|
||||
// Disable dep optimization, as recommended by vite-node's README
|
||||
baseConfig.optimizeDeps ??= {};
|
||||
baseConfig.optimizeDeps.noDiscovery = true;
|
||||
baseConfig.optimizeDeps.include = [];
|
||||
const envConfig: vite.InlineConfig = {
|
||||
plugins: [wxtPlugins.removeEntrypointMainFunction(wxtConfig, path)],
|
||||
};
|
||||
const config = vite.mergeConfig(baseConfig, envConfig);
|
||||
const server = await vite.createServer(config);
|
||||
await server.pluginContainer.buildStart({});
|
||||
const node = new ViteNodeServer(
|
||||
// @ts-ignore: Some weird type error...
|
||||
server,
|
||||
);
|
||||
installSourcemapsSupport({
|
||||
getSourceMap: (source) => node.getSourceMap(source),
|
||||
});
|
||||
const runner = new ViteNodeRunner({
|
||||
root: server.config.root,
|
||||
base: server.config.base,
|
||||
// when having the server and runner in a different context,
|
||||
// you will need to handle the communication between them
|
||||
// and pass to this function
|
||||
fetchModule(id) {
|
||||
return node.fetchModule(id);
|
||||
},
|
||||
resolveId(id, importer) {
|
||||
return node.resolveId(id, importer);
|
||||
},
|
||||
});
|
||||
const res = await runner.executeFile(path);
|
||||
const { runner, server } = await createViteNodeImporter([path]);
|
||||
const res = await env.run(() => runner.executeFile(path));
|
||||
await server.close();
|
||||
requireDefaultExport(path, res);
|
||||
return res.default;
|
||||
}
|
||||
}
|
||||
},
|
||||
async importEntrypoints(paths) {
|
||||
const env = createExtensionEnvironment();
|
||||
switch (wxtConfig.entrypointLoader) {
|
||||
default:
|
||||
case 'jiti': {
|
||||
return await env.run(() =>
|
||||
Promise.all(paths.map(importEntrypointFile)),
|
||||
);
|
||||
}
|
||||
case 'vite-node': {
|
||||
const { runner, server } = await createViteNodeImporter(paths);
|
||||
const res = await env.run(() =>
|
||||
Promise.all(
|
||||
paths.map(async (path) => {
|
||||
const mod = await runner.executeFile(path);
|
||||
requireDefaultExport(path, mod);
|
||||
return mod.default;
|
||||
}),
|
||||
),
|
||||
);
|
||||
await server.close();
|
||||
return res;
|
||||
}
|
||||
}
|
||||
},
|
||||
async build(group) {
|
||||
let entryConfig;
|
||||
if (Array.isArray(group)) entryConfig = getMultiPageConfig(group);
|
||||
@@ -319,6 +372,9 @@ export async function createViteBuilder(
|
||||
},
|
||||
},
|
||||
watcher: viteServer.watcher,
|
||||
on(event, cb) {
|
||||
viteServer.httpServer?.on(event, cb);
|
||||
},
|
||||
};
|
||||
|
||||
return server;
|
||||
|
||||
@@ -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,120 @@ 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 guarantee 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({
|
||||
canReopenBrowser:
|
||||
!wxt.config.runnerConfig.config.disabled && !!runner.canOpen?.(),
|
||||
});
|
||||
},
|
||||
|
||||
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,25 +171,7 @@ 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);
|
||||
builderServer.on?.('close', () => keyboardShortcuts.stop());
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -206,6 +245,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(' | '),
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function initialize(options: {
|
||||
template: string;
|
||||
packageManager: string;
|
||||
}) {
|
||||
consola.info('Initalizing new project');
|
||||
consola.info('Initializing new project');
|
||||
|
||||
const templates = await listTemplates();
|
||||
const defaultTemplate = templates.find(
|
||||
@@ -44,10 +44,7 @@ export async function initialize(options: {
|
||||
{ title: pc.red('npm'), value: 'npm' },
|
||||
{ title: pc.yellow('pnpm'), value: 'pnpm' },
|
||||
{ title: pc.cyan('yarn'), value: 'yarn' },
|
||||
{
|
||||
title: `${pc.magenta('bun')}${pc.gray(' (experimental)')}`,
|
||||
value: 'bun',
|
||||
},
|
||||
{ title: pc.magenta('bun'), value: 'bun' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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(flags: { canReopenBrowser: boolean }): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function that creates a keyboard shortcut handler for the extension.
|
||||
*/
|
||||
export function createKeyboardShortcuts(
|
||||
server: WxtDevServer,
|
||||
): KeyboardShortcutWatcher {
|
||||
let rl: readline.Interface | undefined;
|
||||
|
||||
const handleInput = (line: string) => {
|
||||
// Only handle our specific command
|
||||
if (line.trim() === 'o') {
|
||||
server.restartBrowser();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (rl) return;
|
||||
|
||||
rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
terminal: false, // Don't intercept ctrl+C, ctrl+Z, etc
|
||||
});
|
||||
|
||||
rl.on('line', handleInput);
|
||||
},
|
||||
|
||||
stop() {
|
||||
rl?.close();
|
||||
rl = undefined;
|
||||
},
|
||||
|
||||
printHelp(flags) {
|
||||
if (flags.canReopenBrowser) {
|
||||
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
|
||||
@@ -137,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,
|
||||
};
|
||||
}
|
||||
@@ -386,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;
|
||||
|
||||
@@ -11,6 +11,9 @@ export function createWebExtRunner(): ExtensionRunner {
|
||||
let runner: WebExtRunInstance | undefined;
|
||||
|
||||
return {
|
||||
canOpen() {
|
||||
return true;
|
||||
},
|
||||
async openBrowser() {
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -32,7 +35,7 @@ export function createWebExtRunner(): ExtensionRunner {
|
||||
|
||||
const wxtUserConfig = wxt.config.runnerConfig.config;
|
||||
const userConfig = {
|
||||
console: wxtUserConfig?.openConsole,
|
||||
browserConsole: wxtUserConfig?.openConsole,
|
||||
devtools: wxtUserConfig?.openDevtools,
|
||||
startUrl: wxtUserConfig?.startUrls,
|
||||
keepProfileChanges: wxtUserConfig?.keepProfileChanges,
|
||||
@@ -50,7 +53,10 @@ export function createWebExtRunner(): ExtensionRunner {
|
||||
wxtUserConfig?.chromiumPref,
|
||||
DEFAULT_CHROMIUM_PREFS,
|
||||
),
|
||||
args: wxtUserConfig?.chromiumArgs,
|
||||
args: [
|
||||
'--unsafely-disable-devtools-self-xss-warnings',
|
||||
...(wxtUserConfig?.chromiumArgs ?? []),
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ describe('Manifest Utils', () => {
|
||||
defaultTitle: 'Default Iitle',
|
||||
},
|
||||
outputDir: outDir,
|
||||
skipped: false,
|
||||
});
|
||||
|
||||
it('should include an action for mv3', async () => {
|
||||
@@ -209,6 +210,7 @@ describe('Manifest Utils', () => {
|
||||
chromeStyle: true,
|
||||
browserStyle: true,
|
||||
},
|
||||
skipped: false,
|
||||
});
|
||||
|
||||
it('should include a options_ui and chrome_style for chrome', async () => {
|
||||
@@ -265,6 +267,7 @@ describe('Manifest Utils', () => {
|
||||
persistent: true,
|
||||
type: 'module',
|
||||
},
|
||||
skipped: false,
|
||||
});
|
||||
|
||||
describe('MV3', () => {
|
||||
@@ -368,7 +371,7 @@ describe('Manifest Utils', () => {
|
||||
|
||||
describe('icons', () => {
|
||||
it('should auto-discover icons with the correct name', async () => {
|
||||
const entrypoints = fakeArray(fakeEntrypoint);
|
||||
const entrypoints = fakeArray(() => fakeEntrypoint({ skipped: false }));
|
||||
const buildOutput = fakeBuildOutput({
|
||||
publicAssets: [
|
||||
{ type: 'asset', fileName: 'icon-16.png' },
|
||||
@@ -396,7 +399,7 @@ describe('Manifest Utils', () => {
|
||||
});
|
||||
|
||||
it('should return undefined when no icons are found', async () => {
|
||||
const entrypoints = fakeArray(fakeEntrypoint);
|
||||
const entrypoints = fakeArray(() => fakeEntrypoint({ skipped: false }));
|
||||
const buildOutput = fakeBuildOutput({
|
||||
publicAssets: [
|
||||
{ type: 'asset', fileName: 'logo.png' },
|
||||
@@ -413,7 +416,7 @@ describe('Manifest Utils', () => {
|
||||
});
|
||||
|
||||
it('should allow icons to be overwritten from the wxt.config.ts file', async () => {
|
||||
const entrypoints = fakeArray(fakeEntrypoint);
|
||||
const entrypoints = fakeArray(() => fakeEntrypoint({ skipped: false }));
|
||||
const buildOutput = fakeBuildOutput({
|
||||
publicAssets: [
|
||||
{ type: 'asset', fileName: 'icon-16.png' },
|
||||
@@ -837,6 +840,7 @@ describe('Manifest Utils', () => {
|
||||
options: {
|
||||
registration: 'runtime',
|
||||
},
|
||||
skipped: false,
|
||||
});
|
||||
|
||||
const entrypoints = [cs];
|
||||
@@ -902,6 +906,7 @@ describe('Manifest Utils', () => {
|
||||
async (browser) => {
|
||||
const sidepanel = fakeSidepanelEntrypoint({
|
||||
outputDir: outDir,
|
||||
skipped: false,
|
||||
});
|
||||
const buildOutput = fakeBuildOutput();
|
||||
|
||||
@@ -934,6 +939,7 @@ describe('Manifest Utils', () => {
|
||||
async (browser) => {
|
||||
const sidepanel = fakeSidepanelEntrypoint({
|
||||
outputDir: outDir,
|
||||
skipped: false,
|
||||
});
|
||||
const buildOutput = fakeBuildOutput();
|
||||
|
||||
@@ -1581,6 +1587,64 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not add skipped entrypoints to manifest', async () => {
|
||||
const popup = fakePopupEntrypoint({ skipped: true });
|
||||
const content = fakeContentScriptEntrypoint({ skipped: true });
|
||||
const sidePanel = fakeSidepanelEntrypoint({ skipped: true });
|
||||
const buildOutput = fakeBuildOutput();
|
||||
|
||||
setFakeWxt({
|
||||
config: {
|
||||
command: 'build',
|
||||
manifestVersion: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const { manifest } = await generateManifest(
|
||||
[popup, content, sidePanel],
|
||||
buildOutput,
|
||||
);
|
||||
|
||||
expect(manifest.action).toBeUndefined();
|
||||
expect(manifest.sidebar_action).toBeUndefined();
|
||||
expect(manifest.content_scripts).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -72,9 +72,10 @@ describe('Validation Utils', () => {
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("should return an error when content scripts don't have a matches", () => {
|
||||
it('should return an error when "registration: manifest" content scripts don\'t have matches', () => {
|
||||
const entrypoint = fakeContentScriptEntrypoint({
|
||||
options: {
|
||||
registration: 'manifest',
|
||||
// @ts-expect-error
|
||||
matches: null,
|
||||
},
|
||||
@@ -83,7 +84,8 @@ describe('Validation Utils', () => {
|
||||
errors: [
|
||||
{
|
||||
type: 'error',
|
||||
message: '`matches` is required',
|
||||
message:
|
||||
'`matches` is required for manifest registered content scripts',
|
||||
value: null,
|
||||
entrypoint,
|
||||
},
|
||||
@@ -96,5 +98,24 @@ describe('Validation Utils', () => {
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should allow "registration: runtime" content scripts to not have matches', () => {
|
||||
const entrypoint = fakeContentScriptEntrypoint({
|
||||
options: {
|
||||
registration: 'runtime',
|
||||
// @ts-expect-error
|
||||
matches: null,
|
||||
},
|
||||
});
|
||||
const expected = {
|
||||
errors: [],
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
};
|
||||
|
||||
const actual = validateEntrypoints([entrypoint]);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user