Compare commits

...

12 Commits

Author SHA1 Message Date
Aaron c9205bb08f feat: New @wxt-dev/is-background package (#2152)
📼 VHS / Create VHS (push) Waiting to run
Co-authored-by: Smit <sm17p@users.noreply.github.com>
2026-02-22 13:57:09 -06:00
Tam Dang 05e23e70b4 fix(types): include CSS entrypoints in PublicPath generation (#2150)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2026-02-22 09:00:58 -06:00
Aaron 68fa36ef2b ci: Fix auto-labeling workflow for forks 2026-02-22 08:57:06 -06:00
Aaron 182d575205 feat(modules): Add support for augumenting entrypoint options (#2149) 2026-02-21 15:57:49 -06:00
Aaron 09eadf6796 docs: Add deep outline to modules page to show recipes 2026-02-21 15:57:25 -06:00
Aaron a6904b6319 ci: Create workflow for labeling and adding authors to PRs (#2148) 2026-02-21 15:29:04 -06:00
Nick Doan 42d65cb10b fix(types): Add type safety to browser.runtime.executeScript files option (#2142)
Co-authored-by: nickbar01234 <nickbar01234gmail.com>
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2026-02-21 20:58:15 +00:00
Aaron 636cf1f841 fix: Allow userId option to return undefined 2026-02-21 14:48:28 -06:00
Patryk Kuniczak 3b154508bd chore: Created new types, instead of any for analytics (#2119) 2026-02-21 20:20:42 +00:00
Patryk Kuniczak d34eac9a4c chore: Remove @ts-expect-error from manifest.test.ts and fix typo (#2123) 2026-02-21 14:20:07 -06:00
Patryk Kuniczak 54c6fcca10 chore: Simplify imports in wxt/e2e (#2122) 2026-02-21 18:44:56 +00:00
Patryk Kuniczak 0114ee6a7a chore!: Rename zipdir to zipDir (#2126) 2026-02-21 12:40:34 -06:00
28 changed files with 697 additions and 110 deletions
+111
View File
@@ -0,0 +1,111 @@
name: ✨ Auto-label PR
on:
pull_request_target:
types: [opened, synchronized, reopened]
jobs:
update-pr:
name: Update PR
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Gather Info
id: check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number
});
// Check if PR has assignees
const hasAssignees = pr.assignees && pr.assignees.length > 0;
core.setOutput('has_assignees', hasAssignees);
core.setOutput('author', pr.user.login);
// Get list of changed files
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number
});
// Find all packages that were modified
const packagesRegex = /^packages\/([^\/]+)\//;
const affectedPackages = new Set();
for (const file of files) {
const match = file.filename.match(packagesRegex);
if (match) {
affectedPackages.add(match[1]);
}
}
const labels = Array.from(affectedPackages).map(pkg => `pkg/${pkg}`);
core.setOutput('labels', JSON.stringify(labels));
console.log('Detected package labels:', labels);
// Get current labels on the PR that match pkg/* pattern
const currentPkgLabels = pr.labels
.map(label => label.name)
.filter(name => name.startsWith('pkg/'));
core.setOutput('current_pkg_labels', JSON.stringify(currentPkgLabels));
console.log('Current pkg labels:', currentPkgLabels);
- name: Sync Author
if: steps.check.outputs.has_assignees == 'false'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
assignees: ['${{ steps.check.outputs.author }}']
});
console.log('Assigned PR author: ${{ steps.check.outputs.author }}');
- name: Sync Labels
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const newLabels = ${{ steps.check.outputs.labels }};
const currentLabels = ${{ steps.check.outputs.current_pkg_labels }};
// Find labels to add (in newLabels but not in currentLabels)
const labelsToAdd = newLabels.filter(label => !currentLabels.includes(label));
// Find labels to remove (in currentLabels but not in newLabels)
const labelsToRemove = currentLabels.filter(label => !newLabels.includes(label));
// Add new labels
if (labelsToAdd.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
labels: labelsToAdd
});
console.log('Added labels:', labelsToAdd);
}
// Remove obsolete labels
for (const label of labelsToRemove) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
name: label
});
console.log('Removed label:', label);
}
if (labelsToAdd.length === 0 && labelsToRemove.length === 0) {
console.log('No label changes needed');
}
+1
View File
@@ -10,6 +10,7 @@ on:
- analytics
- auto-icons
- i18n
- is-background
- module-react
- module-solid
- module-svelte
+1
View File
@@ -10,6 +10,7 @@ on:
- analytics
- auto-icons
- i18n
- is-background
- module-react
- module-solid
- module-svelte
-7
View File
@@ -163,13 +163,6 @@ cp -r templates/vanilla templates/<new-template-name>
That's it. Once your template is merged, it will be available inside `wxt init` immediately. You don't need to release a new version of WXT to release a new template.
## Releasing Updates
Releases are done with GitHub actions:
- Use the [Release workflow](https://github.com/wxt-dev/wxt/actions/workflows/release.yml) to release a single package in the monorepo. This automatically detects the version change with conventional commits, builds and uploads the package to NPM, and creates a GitHub release.
- Use the [Sync Releases workflow](https://github.com/wxt-dev/wxt/actions/workflows/sync-releases.yml) to sync the GitHub releases with changes to the changelog. To change a release, update the `CHANGELOG.md` file and run the workflow. It will sync the releases of a single package in the monorepo.
## Upgrading Dependencies
WXT has custom rules around what dependencies can be upgraded. Use the `scripts/upgrade-deps.ts` script to upgrade dependencies and follow these rules.
+41
View File
@@ -47,3 +47,44 @@ Here's an example of how to ask for a reproduction: <https://github.com/wxt-dev/
## Add yourself as a code owner
If you want to be responsible for a specific package or directory, add yourself to the [`.github/CODEOWNERS`](https://github.com/wxt-dev/wxt/blob/main/.github/CODEOWNERS) file to get added as a reviewer to PRs automatically. You can also add yourself to the default list to be added as a reviewer on all PRs.
## Releasing Package Updates
Releases are done with GitHub actions:
- Use the [Release workflow](https://github.com/wxt-dev/wxt/actions/workflows/release.yml) to release a single package in the monorepo. This automatically detects the version change with conventional commits, builds and uploads the package to NPM, and creates a GitHub release.
- Use the [Sync Releases workflow](https://github.com/wxt-dev/wxt/actions/workflows/sync-releases.yml) to sync the GitHub releases with changes to the changelog. To change a release, update the `CHANGELOG.md` file and run the workflow. It will sync the releases of a single package in the monorepo.
## Creating New Packages
Example PR: <https://github.com/wxt-dev/wxt/pull/2152>
1. Create the package.
2. Update CI workflow inputs.
3. Add docs page and version for "Other Packages" dropdown.
4. Merge the PR.
5. Tag the commit (look at other tags for pattern):
```sh
git tag <dir-name>-v<version>
git push --tags
```
6. Publish the package to NPM:
```sh
cd packages/<dir-name>
pnpm publish --access public
```
7. Create a basic release on GitHub mentioning the new package is available.
A couple of things to note:
- pkg.pr.new will fail on the original PR. It's fine to ignore and merge your PR as long as it fails due to your new package not being published to NPM yet.
- The regular release workflow DOES NOT WORK for new packages. You have to have at least one `<dir-name>-v<version>` tag created before you can run that workflow for your new package.
- You don't need to create a CHANGELOG.md file for the package, it will be created automatically after future changes are released via the normal release workflow.
+2
View File
@@ -16,6 +16,7 @@ import { version as unocssVersion } from '../../packages/unocss/package.json';
import { version as storageVersion } from '../../packages/storage/package.json';
import { version as analyticsVersion } from '../../packages/analytics/package.json';
import { version as runnerVersion } from '../../packages/runner/package.json';
import { version as isBackgroundVersion } from '../../packages/is-background/package.json';
import addKnowledge from 'vitepress-knowledge';
import {
groupIconMdPlugin,
@@ -44,6 +45,7 @@ const otherPackages = {
storage: storageVersion,
unocss: unocssVersion,
runner: runnerVersion,
'is-background': isBackgroundVersion,
};
const knowledge = addKnowledge<DefaultTheme.Config>({
+56
View File
@@ -1,3 +1,7 @@
---
outline: deep
---
# WXT Modules
WXT provides a "module system" that let's you run code at different steps in the build process to modify it.
@@ -137,6 +141,58 @@ console.log(config.myModule);
This is very useful when [generating runtime code](#generate-runtime-module).
#### Add custom entrypoint options
Modules can add custom options to entrypoints by augmenting the entrypoint options types. This allows you to add custom configuration that can be accessed during the build process.
```ts
import { defineWxtModule } from 'wxt/modules';
import 'wxt';
declare module 'wxt' {
export interface BackgroundEntrypointOptions {
// Add custom options to the background entrypoint
myCustomOption?: string;
}
}
export default defineWxtModule({
setup(wxt) {
wxt.hook('entrypoints:resolved', (_, entrypoints) => {
const background = entrypoints.find((e) => e.type === 'background');
if (background) {
console.log('Custom option:', background.options.myCustomOption);
}
});
},
});
```
Now users can set the custom option in their entrypoint:
```ts [entrypoints/background.ts]
export default defineBackground({
myCustomOption: 'custom value',
main() {
// ...
},
});
```
This works for all other JS and HTML entrypoints, here's an example of how to pass a custom option from an HTML file.
```html [entrypoints/popup.html]
<html>
<head>
<meta name="wxt.myHtmlOption" content="custom value" />
<title>Popup</title>
</head>
<body>
<!-- ... -->
</body>
</html>
```
#### Generate output file
```ts
+1
View File
@@ -0,0 +1 @@
<!--@include: ../packages/is-background/README.md-->
+36 -19
View File
@@ -2,15 +2,30 @@ import { UAParser } from 'ua-parser-js';
import type {
Analytics,
AnalyticsConfig,
AnalyticsEventMetadata,
AnalyticsPageViewEvent,
AnalyticsProvider,
AnalyticsStorageItem,
AnalyticsTrackEvent,
BaseAnalyticsEvent,
AnalyticsEventMetadata,
AnalyticsProvider,
} from './types';
import { browser } from '@wxt-dev/browser';
type AnalyticsMessage = {
[K in keyof Analytics]: {
fn: K;
args: Parameters<Analytics[K]>;
};
}[keyof Analytics];
type AnalyticsMethod =
| ((...args: Parameters<Analytics[keyof Analytics]>) => void)
| undefined;
type MethodForwarder = <K extends keyof Analytics>(
fn: K,
) => (...args: Parameters<Analytics[K]>) => void;
const ANALYTICS_PORT = '@wxt-dev/analytics';
const INTERACTIVE_TAGS = new Set([
@@ -163,7 +178,7 @@ function createBackgroundAnalytics(
},
track: async (
eventName: string,
eventProperties?: Record<string, string>,
eventProperties?: Record<string, string | undefined>,
meta: AnalyticsEventMetadata = getBackgroundMeta(),
) => {
const baseEvent = await getBaseEvent(meta);
@@ -197,9 +212,8 @@ function createBackgroundAnalytics(
// Listen for messages from the rest of the extension
browser.runtime.onConnect.addListener((port) => {
if (port.name === ANALYTICS_PORT) {
port.onMessage.addListener(({ fn, args }) => {
// @ts-expect-error: Untyped fn key
void analytics[fn]?.(...args);
port.onMessage.addListener(({ fn, args }: AnalyticsMessage) => {
void (analytics[fn] as AnalyticsMethod)?.(...args);
});
}
});
@@ -217,17 +231,15 @@ function createFrontendAnalytics(): Analytics {
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,
referrer: document.referrer || undefined,
screen: `${window.screen.width}x${window.screen.height}`,
url: location.href,
title: document.title || undefined,
});
const methodForwarder =
(fn: string) =>
(...args: any[]) => {
const methodForwarder: MethodForwarder =
(fn) =>
(...args) => {
port.postMessage({ fn, args: [...args, getFrontendMetadata()] });
};
@@ -238,11 +250,11 @@ function createFrontendAnalytics(): Analytics {
setEnabled: methodForwarder('setEnabled'),
autoTrack: (root) => {
const onClick = (event: Event) => {
const element = event.target as any;
const element = event.target as HTMLElement | null;
if (
!element ||
(!INTERACTIVE_TAGS.has(element.tagName) &&
!INTERACTIVE_ROLES.has(element.getAttribute('role')))
!INTERACTIVE_ROLES.has(element.getAttribute('role') ?? ''))
)
return;
@@ -251,7 +263,7 @@ function createFrontendAnalytics(): Analytics {
id: element.id || undefined,
className: element.className || undefined,
textContent: element.textContent?.substring(0, 50) || undefined, // Limit text content length
href: element.href,
href: (element as HTMLAnchorElement).href,
});
};
root.addEventListener('click', onClick, { capture: true, passive: true });
@@ -263,13 +275,18 @@ function createFrontendAnalytics(): Analytics {
return analytics;
}
function defineStorageItem<T>(key: string): AnalyticsStorageItem<T | undefined>;
function defineStorageItem<T>(
key: string,
defaultValue?: NonNullable<T>,
): AnalyticsStorageItem<T> {
defaultValue: T,
): AnalyticsStorageItem<T>;
function defineStorageItem(
key: string,
defaultValue?: unknown,
): AnalyticsStorageItem<unknown> {
return {
getValue: async () =>
(await browser.storage.local.get<Record<string, any>>(key))[key] ??
(await browser.storage.local.get<Record<string, unknown>>(key))[key] ??
defaultValue,
setValue: (newValue) => browser.storage.local.set({ [key]: newValue }),
};
@@ -2,7 +2,10 @@ export interface Analytics {
/** Report a page change. */
page: (url: string) => void;
/** Report a custom event. */
track: (eventName: string, eventProperties?: Record<string, string>) => void;
track: (
eventName: string,
eventProperties?: Record<string, string | undefined>,
) => 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. */
@@ -32,7 +35,7 @@ export interface AnalyticsConfig {
/**
* Configure how the user Id is persisted. Defaults to using `browser.storage.local`.
*/
userId?: AnalyticsStorageItem<string>;
userId?: AnalyticsStorageItem<string | undefined>;
/**
* Configure how user properties are persisted. Defaults to using `browser.storage.local`.
*/
@@ -94,6 +97,6 @@ export interface AnalyticsPageViewEvent extends BaseAnalyticsEvent {
export interface AnalyticsTrackEvent extends BaseAnalyticsEvent {
event: {
name: string;
properties?: Record<string, string>;
properties?: Record<string, string | undefined>;
};
}
+17
View File
@@ -0,0 +1,17 @@
# `@wxt-dev/is-background`
Exports a getter to determine if the current JS context is the background or not.
## Installation
```sh
pnpm add @wxt-dev/is-background
```
## Usage
```ts
import { isBackground } from '@wxt-dev/is-background';
isBackground(); // true | false
```
+56
View File
@@ -0,0 +1,56 @@
{
"name": "@wxt-dev/is-background",
"type": "module",
"version": "1.0.0",
"description": "Check if the current context is the background or not.",
"license": "MIT",
"scripts": {
"build": "buildc -- tsdown",
"check": "pnpm build && check",
"test": "buildc --deps-only -- vitest",
"test:coverage": "pnpm test run --coverage",
"prepack": "pnpm build"
},
"dependencies": {
"@wxt-dev/browser": "workspace:^"
},
"devDependencies": {
"oxlint": "^1.43.0",
"publint": "^0.3.17",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
},
"repository": {
"type": "git",
"url": "git+https://github.com/wxt-dev/wxt.git",
"directory": "packages/is-background"
},
"keywords": [
"wxt",
"chrome",
"web",
"extension",
"is",
"background",
"script",
"page",
"service",
"worker"
],
"author": {
"name": "Aaron Klinker",
"email": "aaronklinker1+wxt@gmail.com"
},
"funding": "https://github.com/sponsors/wxt-dev",
"files": [
"dist"
],
"types": "./dist/index.d.mts",
"module": "./dist/index.mjs",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
}
}
@@ -0,0 +1,145 @@
import { describe, it, vi, expect } from 'vitest';
import { getIsBackground } from '../getter';
let mockBrowser: any;
vi.mock('@wxt-dev/browser', () => ({
get browser() {
return mockBrowser;
},
}));
const backgroundWindow = Symbol('Background');
const otherWindow = Symbol('Other');
function setupEnv(options: {
window: symbol | undefined;
hasExtensionApis: boolean;
hasGetBackgroundPage: boolean;
hasServiceWorkerGlobalScope: boolean;
}): void {
vi.unstubAllGlobals();
mockBrowser = undefined;
if (options.window) {
vi.stubGlobal('window', options.window);
}
if (options.hasExtensionApis) {
mockBrowser = {
runtime: {
id: 'test',
},
...(options.hasGetBackgroundPage && {
extension: {
getBackgroundPage: () => backgroundWindow,
},
}),
};
}
if (options.hasServiceWorkerGlobalScope) {
class ServiceWorkerGlobalScope {}
vi.stubGlobal('ServiceWorkerGlobalScope', ServiceWorkerGlobalScope);
vi.stubGlobal('self', new ServiceWorkerGlobalScope());
}
}
describe('isBackground Getter', () => {
describe('Non-extension contexts', () => {
it('should return false', () => {
setupEnv({
hasServiceWorkerGlobalScope: false,
hasExtensionApis: false,
hasGetBackgroundPage: false,
window: otherWindow,
});
expect(getIsBackground()).toBe(false);
});
});
describe('Chromium & Safari', () => {
const hasExtensionApis = true;
describe('MV2', () => {
const hasServiceWorkerGlobalScope = false;
const hasGetBackgroundPage = true;
it('should return true inside the background page', () => {
setupEnv({
hasServiceWorkerGlobalScope,
hasExtensionApis,
hasGetBackgroundPage,
window: backgroundWindow,
});
expect(getIsBackground()).toBe(true);
});
it('should return false outside the background page', () => {
setupEnv({
hasServiceWorkerGlobalScope,
hasExtensionApis,
hasGetBackgroundPage,
window: otherWindow,
});
expect(getIsBackground()).toBe(false);
});
});
describe('MV3', () => {
const hasGetBackgroundPage = false;
it('should return true inside the service worker', () => {
setupEnv({
hasServiceWorkerGlobalScope: true,
hasExtensionApis,
hasGetBackgroundPage,
window: undefined,
});
expect(getIsBackground()).toBe(true);
});
it('should return false outside the service worker', () => {
setupEnv({
hasServiceWorkerGlobalScope: false,
hasExtensionApis,
hasGetBackgroundPage,
window: otherWindow,
});
expect(getIsBackground()).toBe(false);
});
});
});
describe('Firefox, MV2 & MV3', () => {
const hasServiceWorkerGlobalScope = false;
const hasExtensionApis = true;
const hasGetBackgroundPage = true;
it('should return true inside the background page', () => {
setupEnv({
hasServiceWorkerGlobalScope,
hasExtensionApis,
hasGetBackgroundPage,
window: backgroundWindow,
});
expect(getIsBackground()).toBe(true);
});
it('should return false outside the background page', () => {
setupEnv({
hasServiceWorkerGlobalScope,
hasExtensionApis,
hasGetBackgroundPage,
window: otherWindow,
});
expect(getIsBackground()).toBe(false);
});
});
});
+32
View File
@@ -0,0 +1,32 @@
import { browser } from '@wxt-dev/browser';
declare class ServiceWorkerGlobalScope {}
export function getIsBackground(): boolean {
// Are we in an extension context?
if (!browser?.runtime?.id) return false;
// Is this a true MV3 service worker?
//
// - ✅ Chromium MV3
// - ❌ Firefox MV3 - Uses a non-persistent HTML page instead of a service worker.
// - ✅ Safari MV3
if (
typeof ServiceWorkerGlobalScope !== 'undefined' &&
self instanceof ServiceWorkerGlobalScope
) {
return true;
}
// Is this the background page?
//
// - ✅ Chromium MV2
// - ✅ Firefox MV2
// - ✅ Firefox MV3 - Works with the non-persistent HTML page
// - ✅ Safari MV2
return (
typeof window !== 'undefined' &&
typeof browser.extension?.getBackgroundPage === 'function' &&
browser.extension.getBackgroundPage() === window
);
}
+27
View File
@@ -0,0 +1,27 @@
/**
* This module uses a lazy getter function so the logic isn't ran until it's needed.
*
* This has a few benefits:
* 1. Easier to mock in tests
* 2. Safe to import in NodeJS environments (but it should be safe to run just in-case)
* 3. Keeps startup fast by waiting to run the slow functions (`instanceof` or `browser.extension.getBackgroundPage`) until needed
*
* @module @wxt-dev/is-background
*/
import { getIsBackground } from './getter';
let cached: boolean | undefined;
/**
* Getter that returns if the current context is apart of an extension's
* background or not.
*
* > This function caches the result when called for the first time so it
* > doesn't have to recalculate.
*
* @returns true when in a background page or service worker.
*/
export function isBackground(): boolean {
if (cached == null) cached = getIsBackground();
return cached;
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"exclude": ["node_modules/**", "dist/**"]
}
@@ -43,3 +43,14 @@ export default defineBackground({
storage.setItem('session:startTime', Date.now());
},
});
function _otherTypeChecksNotEvaluated() {
browser.scripting.executeScript({
target: { tabId: 1 },
files: [
'/background.js',
// @ts-expect-error: Should error for non-existing paths
'/other.js',
],
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TestProject } from '../utils';
import { WxtHooks } from '../../src/types';
import type { WxtHooks } from '../../src';
const hooks: WxtHooks = {
ready: vi.fn(),
+2 -2
View File
@@ -1,8 +1,8 @@
import { describe, it, expect, vi } from 'vitest';
import { TestProject } from '../utils';
import type { GenericEntrypoint, InlineConfig } from '../../src/types';
import type { GenericEntrypoint, InlineConfig } from '../../src';
import { readFile } from 'fs-extra';
import { normalizePath } from '../../src/core/utils/paths';
import { normalizePath } from '../../src';
describe('Module Helpers', () => {
describe('options', () => {
@@ -63,6 +63,29 @@ describe('TypeScript Project', () => {
`);
});
it('should include CSS entrypoints in browser.runtime.getURL paths', async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html', '<html></html>');
project.addFile(
'entrypoints/plain.css',
`body {
color: red;
}`,
);
project.addFile(
'entrypoints/overlay.content.css',
`body {
color: blue;
}`,
);
await project.prepare();
const output = await project.serializeFile('.wxt/types/paths.d.ts');
expect(output).toContain('| "/plain.css"');
expect(output).toContain('| "/content-scripts/overlay.css"');
});
it('should augment the types for browser.i18n.getMessage', async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html', '<html></html>');
-17
View File
@@ -1,22 +1,5 @@
// Custom TS definitions for non-TS packages
declare module 'zip-dir' {
// Represents the options object for zipdir function
interface ZipDirOptions {
saveTo?: string;
filter?: (path: string, stat: import('fs').Stats) => boolean;
each?: (path: string) => void;
}
function zipdir(
dirPath: string,
options?: ZipDirOptions,
callback?: (error: Error | null, buffer: Buffer) => void,
): Promise<Buffer>;
export = zipdir;
}
declare module 'web-ext-run' {
export interface WebExtRunInstance {
reloadAllExtensions(): Promise<void>;
+32 -1
View File
@@ -10,6 +10,7 @@
* @module wxt/browser
*/
import { browser as _browser, type Browser } from '@wxt-dev/browser';
import type { ScriptPublicPath } from './utils/inject-script';
/**
* This interface is empty because it is generated per-project when running `wxt prepare`. See:
@@ -23,9 +24,39 @@ export interface WxtRuntime {}
*/
export interface WxtI18n {}
export type WxtBrowser = Omit<typeof _browser, 'runtime' | 'i18n'> & {
type ScriptInjection<Args extends any[], Result> =
Browser.scripting.ScriptInjection<Args, Result> extends infer T
? T extends { files: string[] }
? Omit<T, 'files'> & { files: ScriptPublicPath[] }
: T
: never;
type InjectionResult<Result> = Array<
Browser.scripting.InjectionResult<Awaited<Result>>
>;
export interface WxtScripting {
executeScript: {
/**
* @see {@link Browser.scripting.executeScript}
*/
<Args extends any[], Result>(
injection: ScriptInjection<Args, Result>,
): Promise<InjectionResult<Result>>;
<Args extends any[], Result>(
injection: ScriptInjection<Args, Result>,
callback: (results: InjectionResult<Result>) => void,
): void;
};
}
export type WxtBrowser = Omit<
typeof _browser,
'runtime' | 'i18n' | 'scripting'
> & {
runtime: WxtRuntime & Omit<(typeof _browser)['runtime'], 'getURL'>;
i18n: WxtI18n & Omit<(typeof _browser)['i18n'], 'getMessage'>;
scripting: WxtScripting &
Omit<(typeof _browser)['scripting'], 'executeScript'>;
};
export const browser: WxtBrowser = _browser;
+13 -1
View File
@@ -70,7 +70,7 @@ async function getPathsDeclarationEntry(
getEntrypointBundlePath(
entry,
wxt.config.outDir,
isHtmlEntrypoint(entry) ? '.html' : '.js',
getEntrypointPublicExt(entry),
),
)
.concat(await getPublicFiles());
@@ -107,6 +107,18 @@ declare module "wxt/browser" {
};
}
function getEntrypointPublicExt(entry: Entrypoint): '.html' | '.js' | '.css' {
if (isHtmlEntrypoint(entry)) return '.html';
switch (entry.type) {
case 'content-script-style':
case 'unlisted-style':
return '.css';
default:
return '.js';
}
}
async function getI18nDeclarationEntry(): Promise<WxtDirFileEntry> {
const defaultLocale = wxt.config.manifest.default_locale;
const template = `// Generated by wxt
@@ -42,7 +42,7 @@ describe('Manifest Utils', () => {
defaultIcon: {
'16': '/icon/16.png',
},
defaultTitle: 'Default Iitle',
defaultTitle: 'Default Title',
},
outputDir: outDir,
skipped: false,
@@ -112,7 +112,9 @@ describe('findEntrypoints', () => {
name: 'options',
inputPath: resolve(config.entrypointsDir, 'options.html'),
outputDir: config.outDir,
options: {},
options: {
title: 'Default Title',
},
skipped: false,
},
],
@@ -133,6 +135,7 @@ describe('findEntrypoints', () => {
outputDir: config.outDir,
options: {
openInTab: true,
title: 'Title',
},
skipped: false,
},
@@ -150,6 +153,33 @@ describe('findEntrypoints', () => {
},
);
it('should extract wxt.* meta tags from HTML entrypoints', async () => {
const path = 'popup.html';
const content = `
<html>
<head>
<meta name="manifest.default_icon" content="{ '16': '/icon/16.png' }" />
<meta name="wxt.custom_option" content="custom_value" />
<meta name="wxt.anotherOption" content="true" />
<title>Test Title</title>
</head>
</html>
`;
globMock.mockResolvedValueOnce([path]);
readFileMock.mockResolvedValueOnce(content);
const entrypoints = await findEntrypoints();
expect(entrypoints).toHaveLength(1);
expect(entrypoints[0].options).toMatchObject({
defaultIcon: { '16': '/icon/16.png' },
customOption: 'custom_value',
anotherOption: true,
defaultTitle: 'Test Title',
});
});
it.each<[string, Omit<ContentScriptEntrypoint, 'options'>]>([
[
'content.ts',
@@ -115,20 +115,14 @@ export async function findEntrypoints(): Promise<Entrypoint[]> {
...info,
type,
outputDir: resolve(wxt.config.outDir, CONTENT_SCRIPT_OUT_DIR),
options: {
include: options.include,
exclude: options.exclude,
},
options,
};
default:
return {
...info,
type,
outputDir: wxt.config.outDir,
options: {
include: options.include,
exclude: options.exclude,
},
options,
};
}
}),
@@ -200,7 +194,7 @@ async function importEntrypoints(infos: EntrypointInfo[]) {
return resMap;
}
/** Extract `manifest.` options from meta tags, converting snake_case keys to camelCase */
/** Extract `manifest.` and `wxt.` options from meta tags, converting snake_case keys to camelCase */
async function importHtmlEntrypoint(
info: EntrypointInfo,
): Promise<Record<string, any>> {
@@ -214,9 +208,16 @@ async function importHtmlEntrypoint(
metaTags.forEach((tag) => {
const name = tag.name;
if (!name.startsWith('manifest.')) return;
let key: string;
if (name.startsWith('manifest.')) {
key = camelCase(name.slice(9));
} else if (name.startsWith('wxt.')) {
key = camelCase(name.slice(4));
} else {
return;
}
const key = camelCase(name.slice(9));
try {
res[key] = JSON5.parse(tag.content);
} catch {
@@ -266,24 +267,20 @@ async function getPopupEntrypoint(
info: EntrypointInfo,
options: Record<string, any>,
): Promise<PopupEntrypoint> {
// Extract non-per-browser options
const { themeIcons, title, type, ...perBrowserOptions } = options;
const strictOptions: PopupEntrypoint['options'] = resolvePerBrowserOptions(
{
browserStyle: options.browserStyle,
exclude: options.exclude,
include: options.include,
defaultIcon: options.defaultIcon,
defaultTitle: options.title,
mv2Key: options.type,
defaultArea: options.defaultArea,
...perBrowserOptions,
defaultTitle: title,
mv2Key: type,
},
wxt.config.browser,
);
if (strictOptions.mv2Key && strictOptions.mv2Key !== 'page_action')
strictOptions.mv2Key = 'browser_action';
// themeIcons is an array of objects, not a per-browser option
const themeIcons = options.themeIcons;
return {
type: 'popup',
name: 'popup',
@@ -300,16 +297,7 @@ async function getOptionsEntrypoint(
return {
type: 'options',
name: 'options',
options: resolvePerBrowserOptions(
{
browserStyle: options.browserStyle,
chromeStyle: options.chromeStyle,
exclude: options.exclude,
include: options.include,
openInTab: options.openInTab,
},
wxt.config.browser,
),
options: resolvePerBrowserOptions(options, wxt.config.browser),
inputPath: info.inputPath,
outputDir: wxt.config.outDir,
};
@@ -324,10 +312,7 @@ async function getUnlistedPageEntrypoint(
name: info.name,
inputPath: info.inputPath,
outputDir: wxt.config.outDir,
options: {
include: options.include,
exclude: options.exclude,
},
options,
};
}
@@ -340,13 +325,7 @@ async function getUnlistedScriptEntrypoint(
name,
inputPath,
outputDir: wxt.config.outDir,
options: resolvePerBrowserOptions(
{
include: options.include,
exclude: options.exclude,
},
wxt.config.browser,
),
options: resolvePerBrowserOptions(options, wxt.config.browser),
};
}
@@ -355,15 +334,7 @@ async function getBackgroundEntrypoint(
options: Record<string, any>,
): Promise<BackgroundEntrypoint> {
const strictOptions: BackgroundEntrypoint['options'] =
resolvePerBrowserOptions(
{
include: options.include,
exclude: options.exclude,
persistent: options.persistent,
type: options.type,
},
wxt.config.browser,
);
resolvePerBrowserOptions(options, wxt.config.browser);
if (wxt.config.manifestVersion !== 3) {
delete strictOptions.type;
@@ -400,17 +371,16 @@ async function getSidepanelEntrypoint(
info: EntrypointInfo,
options: Record<string, any>,
): Promise<SidepanelEntrypoint> {
// Extract non-per-browser options and rename title to defaultTitle
const { title, ...perBrowserOptions } = options;
return {
type: 'sidepanel',
name: info.name,
options: resolvePerBrowserOptions(
{
browserStyle: options.browserStyle,
exclude: options.exclude,
include: options.include,
defaultIcon: options.defaultIcon,
defaultTitle: options.title,
openAtInstall: options.openAtInstall,
...perBrowserOptions,
defaultTitle: title,
},
wxt.config.browser,
),
+1
View File
@@ -684,6 +684,7 @@ export interface PopupEntrypointOptions extends BaseEntrypointOptions {
}
export interface OptionsEntrypointOptions extends BaseEntrypointOptions {
title?: string;
openInTab?: PerBrowserOption<boolean>;
browserStyle?: PerBrowserOption<boolean>;
chromeStyle?: PerBrowserOption<boolean>;
+19
View File
@@ -225,6 +225,25 @@ importers:
specifier: workspace:*
version: link:../wxt
packages/is-background:
dependencies:
'@wxt-dev/browser':
specifier: workspace:^
version: link:../browser
devDependencies:
oxlint:
specifier: ^1.43.0
version: 1.43.0
publint:
specifier: ^0.3.17
version: 0.3.17
typescript:
specifier: ^5.9.3
version: 5.9.3
vitest:
specifier: ^4.0.18
version: 4.0.18(@types/node@20.19.32)(happy-dom@20.5.0)(jiti@2.6.1)(sass@1.97.3)(tsx@4.21.0)(yaml@2.8.2)
packages/module-react:
dependencies:
'@vitejs/plugin-react':