From a5ec046346d8dac10f3f84926dafa63402382072 Mon Sep 17 00:00:00 2001 From: Patryk Kuniczak Date: Sat, 21 Feb 2026 19:40:34 +0100 Subject: [PATCH 001/227] chore: Rename `zipdir` to `zipDir` (#2126) --- packages/wxt/src/@types/modules.d.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/wxt/src/@types/modules.d.ts b/packages/wxt/src/@types/modules.d.ts index 99fbc79af..784d9deb9 100644 --- a/packages/wxt/src/@types/modules.d.ts +++ b/packages/wxt/src/@types/modules.d.ts @@ -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; - - export = zipdir; -} - declare module 'web-ext-run' { export interface WebExtRunInstance { reloadAllExtensions(): Promise; From d16f59d33d833fcc3269bdbfd595c0528f5e91a3 Mon Sep 17 00:00:00 2001 From: Patryk Kuniczak Date: Sat, 21 Feb 2026 19:44:56 +0100 Subject: [PATCH 002/227] chore: Simplify imports in `wxt/e2e` (#2122) --- packages/wxt/e2e/tests/hooks.test.ts | 2 +- packages/wxt/e2e/tests/modules.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/wxt/e2e/tests/hooks.test.ts b/packages/wxt/e2e/tests/hooks.test.ts index 9442c4088..47ecb1095 100644 --- a/packages/wxt/e2e/tests/hooks.test.ts +++ b/packages/wxt/e2e/tests/hooks.test.ts @@ -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(), diff --git a/packages/wxt/e2e/tests/modules.test.ts b/packages/wxt/e2e/tests/modules.test.ts index 1a24efc19..4d6af641e 100644 --- a/packages/wxt/e2e/tests/modules.test.ts +++ b/packages/wxt/e2e/tests/modules.test.ts @@ -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', () => { From 206b7731cff3d452fb9250f22f2994b511ca07f1 Mon Sep 17 00:00:00 2001 From: Patryk Kuniczak Date: Sat, 21 Feb 2026 21:20:07 +0100 Subject: [PATCH 003/227] chore: Remove `@ts-expect-error` from manifest.test.ts and fix typo (#2123) --- packages/wxt/src/core/utils/__tests__/manifest.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wxt/src/core/utils/__tests__/manifest.test.ts b/packages/wxt/src/core/utils/__tests__/manifest.test.ts index f5e715e30..5775be31c 100644 --- a/packages/wxt/src/core/utils/__tests__/manifest.test.ts +++ b/packages/wxt/src/core/utils/__tests__/manifest.test.ts @@ -42,7 +42,7 @@ describe('Manifest Utils', () => { defaultIcon: { '16': '/icon/16.png', }, - defaultTitle: 'Default Iitle', + defaultTitle: 'Default Title', }, outputDir: outDir, skipped: false, From 51ff46ec5816c917c4d8fd406736d05b1957f4b6 Mon Sep 17 00:00:00 2001 From: Patryk Kuniczak Date: Sat, 21 Feb 2026 21:20:42 +0100 Subject: [PATCH 004/227] chore: Created new types, instead of `any` for `analytics` (#2119) --- .../analytics/modules/analytics/client.ts | 55 ++++++++++++------- packages/analytics/modules/analytics/types.ts | 7 ++- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/packages/analytics/modules/analytics/client.ts b/packages/analytics/modules/analytics/client.ts index d37d016a5..240bfbb73 100644 --- a/packages/analytics/modules/analytics/client.ts +++ b/packages/analytics/modules/analytics/client.ts @@ -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; + }; +}[keyof Analytics]; + +type AnalyticsMethod = + | ((...args: Parameters) => void) + | undefined; + +type MethodForwarder = ( + fn: K, +) => (...args: Parameters) => void; + const ANALYTICS_PORT = '@wxt-dev/analytics'; const INTERACTIVE_TAGS = new Set([ @@ -163,7 +178,7 @@ function createBackgroundAnalytics( }, track: async ( eventName: string, - eventProperties?: Record, + eventProperties?: Record, 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(key: string): AnalyticsStorageItem; function defineStorageItem( key: string, - defaultValue?: NonNullable, -): AnalyticsStorageItem { + defaultValue: T, +): AnalyticsStorageItem; +function defineStorageItem( + key: string, + defaultValue?: unknown, +): AnalyticsStorageItem { return { getValue: async () => - (await browser.storage.local.get>(key))[key] ?? + (await browser.storage.local.get>(key))[key] ?? defaultValue, setValue: (newValue) => browser.storage.local.set({ [key]: newValue }), }; diff --git a/packages/analytics/modules/analytics/types.ts b/packages/analytics/modules/analytics/types.ts index d14d59f81..8f3f8d6e2 100644 --- a/packages/analytics/modules/analytics/types.ts +++ b/packages/analytics/modules/analytics/types.ts @@ -2,7 +2,10 @@ export interface Analytics { /** Report a page change. */ page: (url: string) => void; /** Report a custom event. */ - track: (eventName: string, eventProperties?: Record) => void; + track: ( + eventName: string, + eventProperties?: Record, + ) => void; /** Save information about the user. */ identify: (userId: string, userProperties?: Record) => void; /** Automatically setup and track user interactions, returning a function to remove any listeners that were setup. */ @@ -94,6 +97,6 @@ export interface AnalyticsPageViewEvent extends BaseAnalyticsEvent { export interface AnalyticsTrackEvent extends BaseAnalyticsEvent { event: { name: string; - properties?: Record; + properties?: Record; }; } From cc935daec035c89ef098f476353bdc283d71f07a Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 21 Feb 2026 14:19:43 -0600 Subject: [PATCH 005/227] fix: Allow `userId` option to return `undefined` --- packages/analytics/modules/analytics/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/analytics/modules/analytics/types.ts b/packages/analytics/modules/analytics/types.ts index 8f3f8d6e2..bf1405c52 100644 --- a/packages/analytics/modules/analytics/types.ts +++ b/packages/analytics/modules/analytics/types.ts @@ -35,7 +35,7 @@ export interface AnalyticsConfig { /** * Configure how the user Id is persisted. Defaults to using `browser.storage.local`. */ - userId?: AnalyticsStorageItem; + userId?: AnalyticsStorageItem; /** * Configure how user properties are persisted. Defaults to using `browser.storage.local`. */ From 4500ca577ff5517c6b2444e426ee0754cb6132d4 Mon Sep 17 00:00:00 2001 From: Nick Doan Date: Sat, 21 Feb 2026 15:58:15 -0500 Subject: [PATCH 006/227] fix(types): Add type safety to `browser.runtime.executeScript` `files` option (#2142) Co-authored-by: nickbar01234 Co-authored-by: Aaron --- .../wxt-demo/src/entrypoints/background.ts | 11 +++++++ packages/wxt/src/browser.ts | 33 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/wxt-demo/src/entrypoints/background.ts b/packages/wxt-demo/src/entrypoints/background.ts index ab38f0c3b..3243b0bc9 100644 --- a/packages/wxt-demo/src/entrypoints/background.ts +++ b/packages/wxt-demo/src/entrypoints/background.ts @@ -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', + ], + }); +} diff --git a/packages/wxt/src/browser.ts b/packages/wxt/src/browser.ts index 0019efede..f205e1481 100644 --- a/packages/wxt/src/browser.ts +++ b/packages/wxt/src/browser.ts @@ -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 & { +type ScriptInjection = + Browser.scripting.ScriptInjection extends infer T + ? T extends { files: string[] } + ? Omit & { files: ScriptPublicPath[] } + : T + : never; +type InjectionResult = Array< + Browser.scripting.InjectionResult> +>; + +export interface WxtScripting { + executeScript: { + /** + * @see {@link Browser.scripting.executeScript} + */ + ( + injection: ScriptInjection, + ): Promise>; + ( + injection: ScriptInjection, + callback: (results: InjectionResult) => 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; From d63be2d6f36290a471ee108e7b1954ac14b3fc67 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 21 Feb 2026 15:29:04 -0600 Subject: [PATCH 007/227] ci: Create workflow for labeling and adding authors to PRs (#2148) --- .github/workflows/auto-label.yml | 111 +++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 .github/workflows/auto-label.yml diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml new file mode 100644 index 000000000..7203d7efb --- /dev/null +++ b/.github/workflows/auto-label.yml @@ -0,0 +1,111 @@ +name: ✨ Auto-label PR + +on: + pull_request: + types: [opened, synchronize] + +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'); + } From 653608c98cdad52446864e6d2a4019d05e81173a Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 21 Feb 2026 15:57:25 -0600 Subject: [PATCH 008/227] docs: Add deep outline to modules page to show recipes --- docs/guide/essentials/wxt-modules.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/guide/essentials/wxt-modules.md b/docs/guide/essentials/wxt-modules.md index f8171e8fa..6296e2611 100644 --- a/docs/guide/essentials/wxt-modules.md +++ b/docs/guide/essentials/wxt-modules.md @@ -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. From d982f97bd8a600dfad1ba6747b61fad4a393afc0 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 21 Feb 2026 15:57:49 -0600 Subject: [PATCH 009/227] feat(modules): Add support for augumenting entrypoint options (#2149) --- docs/guide/essentials/wxt-modules.md | 52 ++++++++++++ .../__tests__/find-entrypoints.test.ts | 32 ++++++- .../core/utils/building/find-entrypoints.ts | 84 ++++++------------- packages/wxt/src/types.ts | 1 + 4 files changed, 111 insertions(+), 58 deletions(-) diff --git a/docs/guide/essentials/wxt-modules.md b/docs/guide/essentials/wxt-modules.md index 6296e2611..22ac97634 100644 --- a/docs/guide/essentials/wxt-modules.md +++ b/docs/guide/essentials/wxt-modules.md @@ -141,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] + + + + Popup + + + + + +``` + #### Generate output file ```ts diff --git a/packages/wxt/src/core/utils/building/__tests__/find-entrypoints.test.ts b/packages/wxt/src/core/utils/building/__tests__/find-entrypoints.test.ts index 3355bd51b..a8edb2156 100644 --- a/packages/wxt/src/core/utils/building/__tests__/find-entrypoints.test.ts +++ b/packages/wxt/src/core/utils/building/__tests__/find-entrypoints.test.ts @@ -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 = ` + + + + + + Test Title + + + `; + + 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]>([ [ 'content.ts', diff --git a/packages/wxt/src/core/utils/building/find-entrypoints.ts b/packages/wxt/src/core/utils/building/find-entrypoints.ts index 81abc0715..f6992fe0d 100644 --- a/packages/wxt/src/core/utils/building/find-entrypoints.ts +++ b/packages/wxt/src/core/utils/building/find-entrypoints.ts @@ -115,20 +115,14 @@ export async function findEntrypoints(): Promise { ...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> { @@ -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, ): Promise { + // 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, ): Promise { 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, ): Promise { + // 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, ), diff --git a/packages/wxt/src/types.ts b/packages/wxt/src/types.ts index 43ba58ea0..04b5306e4 100644 --- a/packages/wxt/src/types.ts +++ b/packages/wxt/src/types.ts @@ -684,6 +684,7 @@ export interface PopupEntrypointOptions extends BaseEntrypointOptions { } export interface OptionsEntrypointOptions extends BaseEntrypointOptions { + title?: string; openInTab?: PerBrowserOption; browserStyle?: PerBrowserOption; chromeStyle?: PerBrowserOption; From b4be5feaa6650bbbe122243e0a3135248217fc77 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sun, 22 Feb 2026 08:57:06 -0600 Subject: [PATCH 010/227] ci: Fix auto-labeling workflow for forks --- .github/workflows/auto-label.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 7203d7efb..bae5e76fc 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -1,8 +1,8 @@ name: ✨ Auto-label PR on: - pull_request: - types: [opened, synchronize] + pull_request_target: + types: [opened, synchronized, reopened] jobs: update-pr: From 078fd5990ea32fcd29d42e76569b1de56c4ab9f0 Mon Sep 17 00:00:00 2001 From: Tam Dang <139360620+dahomita@users.noreply.github.com> Date: Sun, 22 Feb 2026 10:00:58 -0500 Subject: [PATCH 011/227] fix(types): include CSS entrypoints in PublicPath generation (#2150) Co-authored-by: Aaron --- .../wxt/e2e/tests/typescript-project.test.ts | 23 +++++++++++++++++++ packages/wxt/src/core/generate-wxt-dir.ts | 14 ++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/wxt/e2e/tests/typescript-project.test.ts b/packages/wxt/e2e/tests/typescript-project.test.ts index 35d35a6aa..663c5d281 100644 --- a/packages/wxt/e2e/tests/typescript-project.test.ts +++ b/packages/wxt/e2e/tests/typescript-project.test.ts @@ -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', ''); + 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', ''); diff --git a/packages/wxt/src/core/generate-wxt-dir.ts b/packages/wxt/src/core/generate-wxt-dir.ts index c0d00381f..6832c7446 100644 --- a/packages/wxt/src/core/generate-wxt-dir.ts +++ b/packages/wxt/src/core/generate-wxt-dir.ts @@ -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 { const defaultLocale = wxt.config.manifest.default_locale; const template = `// Generated by wxt From 38530cdb398d500b63a31c28ffc2491b0770c86a Mon Sep 17 00:00:00 2001 From: Aaron Date: Sun, 22 Feb 2026 13:57:09 -0600 Subject: [PATCH 012/227] feat: New `@wxt-dev/is-background` package (#2152) Co-authored-by: Smit --- .github/workflows/release.yml | 1 + .github/workflows/sync-releases.yml | 1 + CONTRIBUTING.md | 7 - MAINTAINERS.md | 41 +++++ docs/.vitepress/config.ts | 2 + docs/is-background.md | 1 + packages/is-background/README.md | 17 ++ packages/is-background/package.json | 56 +++++++ .../src/__tests__/getter.test.ts | 145 ++++++++++++++++++ packages/is-background/src/getter.ts | 32 ++++ packages/is-background/src/index.ts | 27 ++++ packages/is-background/tsconfig.json | 4 + pnpm-lock.yaml | 19 +++ 13 files changed, 346 insertions(+), 7 deletions(-) create mode 100644 docs/is-background.md create mode 100644 packages/is-background/README.md create mode 100644 packages/is-background/package.json create mode 100644 packages/is-background/src/__tests__/getter.test.ts create mode 100644 packages/is-background/src/getter.ts create mode 100644 packages/is-background/src/index.ts create mode 100644 packages/is-background/tsconfig.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2eef0eeb..ab5bcfef4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,7 @@ on: - analytics - auto-icons - i18n + - is-background - module-react - module-solid - module-svelte diff --git a/.github/workflows/sync-releases.yml b/.github/workflows/sync-releases.yml index b26061bcc..c12a50b36 100644 --- a/.github/workflows/sync-releases.yml +++ b/.github/workflows/sync-releases.yml @@ -10,6 +10,7 @@ on: - analytics - auto-icons - i18n + - is-background - module-react - module-solid - module-svelte diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38315f909..3bf3adc52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -163,13 +163,6 @@ cp -r templates/vanilla templates/ 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. diff --git a/MAINTAINERS.md b/MAINTAINERS.md index b25d31fb9..2e9bafc05 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -47,3 +47,44 @@ Here's an example of how to ask for a reproduction: + +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 -v + git push --tags + ``` + +6. Publish the package to NPM: + + ```sh + cd packages/ + 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 `-v` 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. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index cb0e9f599..6b2e9041b 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -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({ diff --git a/docs/is-background.md b/docs/is-background.md new file mode 100644 index 000000000..8b99821fd --- /dev/null +++ b/docs/is-background.md @@ -0,0 +1 @@ + diff --git a/packages/is-background/README.md b/packages/is-background/README.md new file mode 100644 index 000000000..f1afc0539 --- /dev/null +++ b/packages/is-background/README.md @@ -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 +``` diff --git a/packages/is-background/package.json b/packages/is-background/package.json new file mode 100644 index 000000000..fb1d17a6d --- /dev/null +++ b/packages/is-background/package.json @@ -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" + } + } +} diff --git a/packages/is-background/src/__tests__/getter.test.ts b/packages/is-background/src/__tests__/getter.test.ts new file mode 100644 index 000000000..65be10076 --- /dev/null +++ b/packages/is-background/src/__tests__/getter.test.ts @@ -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); + }); + }); +}); diff --git a/packages/is-background/src/getter.ts b/packages/is-background/src/getter.ts new file mode 100644 index 000000000..700f98bce --- /dev/null +++ b/packages/is-background/src/getter.ts @@ -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 + ); +} diff --git a/packages/is-background/src/index.ts b/packages/is-background/src/index.ts new file mode 100644 index 000000000..4b03d4d1e --- /dev/null +++ b/packages/is-background/src/index.ts @@ -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; +} diff --git a/packages/is-background/tsconfig.json b/packages/is-background/tsconfig.json new file mode 100644 index 000000000..9b2dc061d --- /dev/null +++ b/packages/is-background/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "exclude": ["node_modules/**", "dist/**"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2068c0d6d..b98e70129 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': From b80556498d0c5a817ac1373e2995eaa4b2d3da55 Mon Sep 17 00:00:00 2001 From: Smit <11639260+sm17p@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:15:16 -0500 Subject: [PATCH 013/227] fix: Improve background script detection logic for analytics package (#1808) Co-authored-by: Aaron --- .../analytics/modules/analytics/client.ts | 6 +-- packages/analytics/package.json | 47 ++++++++++--------- pnpm-lock.yaml | 3 ++ 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/packages/analytics/modules/analytics/client.ts b/packages/analytics/modules/analytics/client.ts index 240bfbb73..a06395b14 100644 --- a/packages/analytics/modules/analytics/client.ts +++ b/packages/analytics/modules/analytics/client.ts @@ -10,6 +10,7 @@ import type { BaseAnalyticsEvent, } from './types'; import { browser } from '@wxt-dev/browser'; +import { isBackground } from '@wxt-dev/is-background'; type AnalyticsMessage = { [K in keyof Analytics]: { @@ -55,10 +56,7 @@ export function createAnalytics(config?: AnalyticsConfig): Analytics { ); } - // 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); + if (isBackground()) return createBackgroundAnalytics(config); return createFrontendAnalytics(); } diff --git a/packages/analytics/package.json b/packages/analytics/package.json index d87c58003..488b54356 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -2,13 +2,35 @@ "name": "@wxt-dev/analytics", "version": "0.5.2", "description": "Add analytics to your web extension", + "type": "module", + "scripts": { + "dev": "buildc --deps-only -- wxt", + "dev:build": "buildc --deps-only -- wxt build", + "check": "pnpm build && check", + "build": "buildc -- tsdown", + "prepack": "pnpm -s build", + "prepare": "buildc --deps-only -- wxt prepare" + }, + "dependencies": { + "@wxt-dev/browser": "workspace:^", + "@wxt-dev/is-background": "workspace:^", + "ua-parser-js": "^1.0.40" + }, + "peerDependencies": { + "wxt": ">=0.20.0" + }, + "devDependencies": { + "@types/ua-parser-js": "^0.7.39", + "publint": "^0.3.17", + "typescript": "^5.9.3", + "wxt": "workspace:*" + }, "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", @@ -38,26 +60,5 @@ "types": "./dist/index.d.mts", "files": [ "dist" - ], - "scripts": { - "dev": "buildc --deps-only -- wxt", - "dev:build": "buildc --deps-only -- wxt build", - "check": "pnpm build && check", - "build": "buildc -- tsdown", - "prepack": "pnpm -s build", - "prepare": "buildc --deps-only -- wxt prepare" - }, - "peerDependencies": { - "wxt": ">=0.20.0" - }, - "devDependencies": { - "@types/ua-parser-js": "^0.7.39", - "publint": "^0.3.17", - "typescript": "^5.9.3", - "wxt": "workspace:*" - }, - "dependencies": { - "@wxt-dev/browser": "workspace:^", - "ua-parser-js": "^1.0.40" - } + ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b98e70129..4413135de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: '@wxt-dev/browser': specifier: workspace:^ version: link:../browser + '@wxt-dev/is-background': + specifier: workspace:^ + version: link:../is-background ua-parser-js: specifier: ^1.0.40 version: 1.0.41 From cd7aec6752ea4a79582c530e79fede39b6639174 Mon Sep 17 00:00:00 2001 From: omerfardemir Date: Sun, 22 Feb 2026 23:57:03 +0300 Subject: [PATCH 014/227] fix: auto icons override default icons (#1616) Co-authored-by: Florian Metz Co-authored-by: Aaron --- packages/auto-icons/package.json | 4 +- .../auto-icons/src/__test__/index.test.ts | 629 ++++++++++++++++++ packages/auto-icons/src/index.ts | 2 +- packages/auto-icons/vitest.config.ts | 8 + pnpm-lock.yaml | 3 + 5 files changed, 644 insertions(+), 2 deletions(-) create mode 100644 packages/auto-icons/src/__test__/index.test.ts create mode 100644 packages/auto-icons/vitest.config.ts diff --git a/packages/auto-icons/package.json b/packages/auto-icons/package.json index d0ecd451f..57b29421d 100644 --- a/packages/auto-icons/package.json +++ b/packages/auto-icons/package.json @@ -39,7 +39,8 @@ ], "scripts": { "build": "buildc -- tsdown", - "check": "pnpm build && check" + "check": "pnpm build && check", + "test": "buildc --deps-only -- vitest" }, "peerDependencies": { "wxt": ">=0.19.0" @@ -53,6 +54,7 @@ "oxlint": "^1.43.0", "publint": "^0.3.17", "typescript": "^5.9.3", + "vitest": "^4.0.18", "wxt": "workspace:*" } } diff --git a/packages/auto-icons/src/__test__/index.test.ts b/packages/auto-icons/src/__test__/index.test.ts new file mode 100644 index 000000000..abe1732c3 --- /dev/null +++ b/packages/auto-icons/src/__test__/index.test.ts @@ -0,0 +1,629 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { resolve } from 'node:path'; +import * as fsExtra from 'fs-extra'; +import sharp from 'sharp'; +import type { Wxt, UserManifest } from 'wxt'; + +// Import the actual module +import autoIconsModule from '../index'; +import type { AutoIconsOptions } from '../index'; + +// Mock dependencies +vi.mock('fs-extra', () => ({ + ensureDir: vi.fn(), + pathExists: vi.fn(), +})); + +vi.mock('sharp', () => ({ + default: vi.fn(), +})); + +// Type definitions for better type safety +interface MockWxt { + config: { + srcDir: string; + outDir: string; + mode: 'development' | 'production'; + }; + logger: { + warn: Mock; + }; + hooks: { + hook: Mock; + }; +} + +interface PublicAsset { + type: string; + fileName: string; +} + +interface BuildOutput { + publicAssets: PublicAsset[]; +} + +describe('auto-icons module', () => { + const mockWxt: MockWxt = { + config: { + srcDir: '/mock/src', + outDir: '/mock/dist', + mode: 'development', + }, + logger: { + warn: vi.fn(), + }, + hooks: { + hook: vi.fn(), + }, + }; + + const createMockSharpInstance = () => { + const instance = { + png: vi.fn(), + grayscale: vi.fn(), + resize: vi.fn(), + toFile: vi.fn().mockResolvedValue(undefined), + }; + + // Make methods chainable + instance.png.mockReturnValue(instance); + instance.grayscale.mockReturnValue(instance); + instance.resize.mockImplementation(() => { + // Create a new instance for each resize to simulate real sharp behavior + const resizedInstance = { ...instance }; + resizedInstance.toFile = vi.fn().mockResolvedValue(undefined); + return resizedInstance; + }); + + return instance; + }; + + let mockSharpInstance: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockSharpInstance = createMockSharpInstance(); + vi.mocked(sharp).mockReturnValue( + mockSharpInstance as unknown as sharp.Sharp, + ); + vi.mocked(fsExtra.pathExists).mockResolvedValue(true as any); + vi.mocked(fsExtra.ensureDir).mockResolvedValue(undefined as any); + }); + + describe('module setup', () => { + it('should have correct module metadata', () => { + expect(autoIconsModule.name).toBe('@wxt-dev/auto-icons'); + expect(autoIconsModule.configKey).toBe('autoIcons'); + expect(typeof autoIconsModule.setup).toBe('function'); + }); + }); + + describe('options handling', () => { + it('should use default options when not provided', async () => { + const options: AutoIconsOptions = {}; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + // Verify that the module was set up (hooks were registered) + expect(mockWxt.hooks.hook).toHaveBeenCalledWith( + 'build:manifestGenerated', + expect.any(Function), + ); + expect(mockWxt.hooks.hook).toHaveBeenCalledWith( + 'build:done', + expect.any(Function), + ); + expect(mockWxt.hooks.hook).toHaveBeenCalledWith( + 'prepare:publicPaths', + expect.any(Function), + ); + }); + + it('should merge custom options with defaults', async () => { + const options: AutoIconsOptions = { + sizes: [64, 32], + grayscaleOnDevelopment: false, + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + // Verify that the module was set up with custom options + expect(mockWxt.hooks.hook).toHaveBeenCalledWith( + 'build:manifestGenerated', + expect.any(Function), + ); + expect(mockWxt.hooks.hook).toHaveBeenCalledWith( + 'build:done', + expect.any(Function), + ); + expect(mockWxt.hooks.hook).toHaveBeenCalledWith( + 'prepare:publicPaths', + expect.any(Function), + ); + }); + }); + + describe('error handling', () => { + it('should warn when disabled', async () => { + const options: AutoIconsOptions = { + enabled: false, + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + expect(mockWxt.logger.warn).toHaveBeenCalledWith( + '`[auto-icons]` @wxt-dev/auto-icons disabled', + ); + expect(mockWxt.hooks.hook).not.toHaveBeenCalled(); + }); + + it('should warn when base icon not found', async () => { + vi.mocked(fsExtra.pathExists).mockResolvedValue(false as any); + + const options: AutoIconsOptions = { + enabled: true, + baseIconPath: 'assets/missing-icon.png', + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + expect(mockWxt.logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Skipping icon generation, no base icon found at', + ), + ); + expect(mockWxt.hooks.hook).not.toHaveBeenCalled(); + }); + }); + + describe('manifest generation hook', () => { + it('should update manifest with default icons when no custom sizes provided', async () => { + const options: AutoIconsOptions = { + enabled: true, + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const manifestHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:manifestGenerated')?.[1]; + + expect(manifestHook).toBeDefined(); + + const manifest: UserManifest = {}; + if (manifestHook) { + await manifestHook(mockWxt as unknown as Wxt, manifest); + } + + // Should use default sizes: [128, 48, 32, 16] + expect(manifest.icons).toEqual({ + 128: 'icons/128.png', + 48: 'icons/48.png', + 32: 'icons/32.png', + 16: 'icons/16.png', + }); + }); + + it('should merge custom sizes with defaults', async () => { + const options: AutoIconsOptions = { + enabled: true, + sizes: [96, 64], // These will be merged with defaults + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const manifestHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:manifestGenerated')?.[1]; + + expect(manifestHook).toBeDefined(); + + const manifest: UserManifest = {}; + if (manifestHook) { + await manifestHook(mockWxt as unknown as Wxt, manifest); + } + + // defu merges arrays, so we get both custom and default sizes + expect(manifest.icons).toEqual({ + 96: 'icons/96.png', + 64: 'icons/64.png', + 128: 'icons/128.png', + 48: 'icons/48.png', + 32: 'icons/32.png', + 16: 'icons/16.png', + }); + }); + + it('should warn when overwriting existing icons in manifest', async () => { + const options: AutoIconsOptions = { + enabled: true, + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const manifestHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:manifestGenerated')?.[1]; + + const manifest: UserManifest = { + icons: { + 128: 'existing-icon.png', + }, + }; + + if (manifestHook) { + await manifestHook(mockWxt as unknown as Wxt, manifest); + } + + expect(mockWxt.logger.warn).toHaveBeenCalledWith( + '`[auto-icons]` icons property found in manifest, overwriting with auto-generated icons', + ); + }); + }); + + describe('icon generation hook', () => { + it('should generate icons with default sizes', async () => { + const options: AutoIconsOptions = { + enabled: true, + }; + + const output: BuildOutput = { + publicAssets: [], + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const buildHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + + expect(buildHook).toBeDefined(); + if (buildHook) { + await buildHook(mockWxt as unknown as Wxt, output); + } + + expect(sharp).toHaveBeenCalledWith( + resolve('/mock/src', 'assets/icon.png'), + ); + expect(mockSharpInstance.png).toHaveBeenCalled(); + + // Should resize to default sizes + expect(mockSharpInstance.resize).toHaveBeenCalledWith(128); + expect(mockSharpInstance.resize).toHaveBeenCalledWith(48); + expect(mockSharpInstance.resize).toHaveBeenCalledWith(32); + expect(mockSharpInstance.resize).toHaveBeenCalledWith(16); + + expect(fsExtra.ensureDir).toHaveBeenCalledWith( + resolve('/mock/dist', 'icons'), + ); + + expect(output.publicAssets).toEqual([ + { type: 'asset', fileName: 'icons/128.png' }, + { type: 'asset', fileName: 'icons/48.png' }, + { type: 'asset', fileName: 'icons/32.png' }, + { type: 'asset', fileName: 'icons/16.png' }, + ]); + }); + + it('should generate icons with custom sizes merged with defaults', async () => { + const options: AutoIconsOptions = { + enabled: true, + sizes: [96, 64], + }; + + const output: BuildOutput = { + publicAssets: [], + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const buildHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + + expect(buildHook).toBeDefined(); + if (buildHook) { + await buildHook(mockWxt as unknown as Wxt, output); + } + + // Should include both custom and default sizes + expect(mockSharpInstance.resize).toHaveBeenCalledWith(96); + expect(mockSharpInstance.resize).toHaveBeenCalledWith(64); + expect(mockSharpInstance.resize).toHaveBeenCalledWith(128); + expect(mockSharpInstance.resize).toHaveBeenCalledWith(48); + expect(mockSharpInstance.resize).toHaveBeenCalledWith(32); + expect(mockSharpInstance.resize).toHaveBeenCalledWith(16); + + expect(output.publicAssets).toEqual([ + { type: 'asset', fileName: 'icons/96.png' }, + { type: 'asset', fileName: 'icons/64.png' }, + { type: 'asset', fileName: 'icons/128.png' }, + { type: 'asset', fileName: 'icons/48.png' }, + { type: 'asset', fileName: 'icons/32.png' }, + { type: 'asset', fileName: 'icons/16.png' }, + ]); + }); + + it('should apply grayscale in development mode', async () => { + const options: AutoIconsOptions = { + enabled: true, + grayscaleOnDevelopment: true, + sizes: [128], + }; + + const output: BuildOutput = { publicAssets: [] }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const buildHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + + if (buildHook) { + await buildHook(mockWxt as unknown as Wxt, output); + } + + expect(mockSharpInstance.grayscale).toHaveBeenCalled(); + }); + + it('should not apply grayscale in production mode', async () => { + const prodMockWxt = { + ...mockWxt, + config: { + ...mockWxt.config, + mode: 'production' as const, + }, + }; + + const options: AutoIconsOptions = { + enabled: true, + grayscaleOnDevelopment: true, + sizes: [128], + }; + + const output: BuildOutput = { publicAssets: [] }; + + await autoIconsModule.setup!(prodMockWxt as unknown as Wxt, options); + + const buildHook = vi + .mocked(prodMockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + + if (buildHook) { + await buildHook(prodMockWxt as unknown as Wxt, output); + } + + expect(mockSharpInstance.grayscale).not.toHaveBeenCalled(); + }); + + it('should not apply grayscale when disabled', async () => { + const options: AutoIconsOptions = { + enabled: true, + grayscaleOnDevelopment: false, + sizes: [128], + }; + + const output: BuildOutput = { publicAssets: [] }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const buildHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + + if (buildHook) { + await buildHook(mockWxt as unknown as Wxt, output); + } + + expect(mockSharpInstance.grayscale).not.toHaveBeenCalled(); + }); + }); + + describe('public paths hook', () => { + it('should add default icon paths to public paths', async () => { + const options: AutoIconsOptions = { + enabled: true, + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const pathsHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'prepare:publicPaths')?.[1]; + + expect(pathsHook).toBeDefined(); + + const paths: string[] = []; + if (pathsHook) { + pathsHook(mockWxt as unknown as Wxt, paths); + } + + expect(paths).toEqual([ + 'icons/128.png', + 'icons/48.png', + 'icons/32.png', + 'icons/16.png', + ]); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle empty sizes array', async () => { + const options: AutoIconsOptions = { + enabled: true, + sizes: [], // Empty array should still merge with defaults + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const manifestHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:manifestGenerated')?.[1]; + + const manifest: UserManifest = {}; + if (manifestHook) { + await manifestHook(mockWxt as unknown as Wxt, manifest); + } + + // Should still have default sizes due to defu merge + expect(manifest.icons).toEqual({ + 128: 'icons/128.png', + 48: 'icons/48.png', + 32: 'icons/32.png', + 16: 'icons/16.png', + }); + }); + + it('should handle sharp processing errors gracefully', async () => { + const options: AutoIconsOptions = { + enabled: true, + }; + + // Make toFile throw an error - need to properly chain resize -> png -> toFile + const errorInstance = { + toFile: vi.fn().mockRejectedValue(new Error('Sharp processing failed')), + grayscale: vi.fn(), + composite: vi.fn(), + }; + errorInstance.grayscale.mockReturnValue(errorInstance); + errorInstance.composite.mockReturnValue(errorInstance); + + mockSharpInstance.resize = vi.fn().mockImplementation(() => ({ + png: vi.fn().mockReturnValue(errorInstance), + })); + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const buildHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + + const output: BuildOutput = { publicAssets: [] }; + + // Should throw the sharp error + if (buildHook) { + await expect( + buildHook(mockWxt as unknown as Wxt, output), + ).rejects.toThrow('Sharp processing failed'); + } + }); + + it('should handle file system errors during directory creation', async () => { + const options: AutoIconsOptions = { + enabled: true, + }; + + // Make ensureDir throw an error + vi.mocked(fsExtra.ensureDir).mockRejectedValue( + new Error('Directory creation failed'), + ); + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const buildHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + + const output: BuildOutput = { publicAssets: [] }; + + // The module doesn't await ensureDir, so it won't throw + if (buildHook) { + await buildHook(mockWxt as unknown as Wxt, output); + // But ensureDir should have been called + expect(fsExtra.ensureDir).toHaveBeenCalled(); + } + }); + + it('should handle custom base icon path correctly', async () => { + const customPath = 'custom/icon.png'; + const options: AutoIconsOptions = { + enabled: true, + baseIconPath: customPath, + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const buildHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + + const output: BuildOutput = { publicAssets: [] }; + + if (buildHook) { + await buildHook(mockWxt as unknown as Wxt, output); + } + + // Should resolve the path relative to srcDir + expect(sharp).toHaveBeenCalledWith(resolve('/mock/src', customPath)); + }); + }); + + describe('integration test', () => { + it('should handle full workflow correctly', async () => { + const options: AutoIconsOptions = { + enabled: true, + baseIconPath: 'assets/custom-icon.png', + sizes: [96], // Will be merged with defaults + grayscaleOnDevelopment: false, + }; + + const manifest: UserManifest = {}; + const output: BuildOutput = { publicAssets: [] }; + const paths: string[] = []; + + // Setup the module + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + // Execute all hooks + const manifestHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:manifestGenerated')?.[1]; + const buildHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + const pathsHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'prepare:publicPaths')?.[1]; + + if (manifestHook) { + await manifestHook(mockWxt as unknown as Wxt, manifest); + } + if (buildHook) { + await buildHook(mockWxt as unknown as Wxt, output); + } + if (pathsHook) { + pathsHook(mockWxt as unknown as Wxt, paths); + } + + // Verify results - defu merges arrays + expect(manifest.icons).toEqual({ + 96: 'icons/96.png', + 128: 'icons/128.png', + 48: 'icons/48.png', + 32: 'icons/32.png', + 16: 'icons/16.png', + }); + + expect(output.publicAssets).toEqual([ + { type: 'asset', fileName: 'icons/96.png' }, + { type: 'asset', fileName: 'icons/128.png' }, + { type: 'asset', fileName: 'icons/48.png' }, + { type: 'asset', fileName: 'icons/32.png' }, + { type: 'asset', fileName: 'icons/16.png' }, + ]); + + expect(paths).toEqual([ + 'icons/96.png', + 'icons/128.png', + 'icons/48.png', + 'icons/32.png', + 'icons/16.png', + ]); + + expect(sharp).toHaveBeenCalledWith( + resolve('/mock/src', 'assets/custom-icon.png'), + ); + expect(mockSharpInstance.grayscale).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/auto-icons/src/index.ts b/packages/auto-icons/src/index.ts index b13616e53..1c8fb8c96 100644 --- a/packages/auto-icons/src/index.ts +++ b/packages/auto-icons/src/index.ts @@ -45,7 +45,7 @@ export default defineWxtModule({ wxt.hooks.hook('build:manifestGenerated', async (wxt, manifest) => { if (manifest.icons) - return wxt.logger.warn( + wxt.logger.warn( '`[auto-icons]` icons property found in manifest, overwriting with auto-generated icons', ); diff --git a/packages/auto-icons/vitest.config.ts b/packages/auto-icons/vitest.config.ts new file mode 100644 index 000000000..d6d6dd072 --- /dev/null +++ b/packages/auto-icons/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + mockReset: true, + restoreMocks: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4413135de..082bf40e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,6 +162,9 @@ importers: 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) wxt: specifier: workspace:* version: link:../wxt From 68dd3fc027359c18003907092105e2a591107693 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sun, 22 Feb 2026 14:59:16 -0600 Subject: [PATCH 015/227] docs: Update section in SECURITY.md around reporting vulnerabilities (#2147) --- SECURITY.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 81250dbca..4cd07775d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,12 +4,8 @@ While WXT is in prerelease, only the latest version will receive security update npm version - +If the vulnerability is accepted, I will open a public issue to track the fix. If the vulnerability is not accepted, no further action will be taken. From c6fdc5a5d5136b53a4e1ae6c61f32e638774912a Mon Sep 17 00:00:00 2001 From: Jonathan Viney Date: Mon, 23 Feb 2026 10:05:38 +1300 Subject: [PATCH 016/227] feat: Add `globalName` entrypoint option. (#2017) --- packages/wxt/e2e/tests/modules.test.ts | 4 +- .../wxt/e2e/tests/output-structure.test.ts | 100 ++++++++++++++++++ packages/wxt/src/core/builders/vite/index.ts | 14 ++- .../builders/vite/plugins/iifeAnonymous.ts | 17 +++ .../src/core/builders/vite/plugins/index.ts | 1 + .../core/utils/building/find-entrypoints.ts | 3 +- .../src/core/utils/testing/fake-objects.ts | 4 +- packages/wxt/src/types.ts | 32 +++++- 8 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 packages/wxt/src/core/builders/vite/plugins/iifeAnonymous.ts diff --git a/packages/wxt/e2e/tests/modules.test.ts b/packages/wxt/e2e/tests/modules.test.ts index 4d6af641e..af3ee0cf6 100644 --- a/packages/wxt/e2e/tests/modules.test.ts +++ b/packages/wxt/e2e/tests/modules.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { TestProject } from '../utils'; -import type { GenericEntrypoint, InlineConfig } from '../../src'; +import type { InlineConfig, UnlistedScriptEntrypoint } from '../../src'; import { readFile } from 'fs-extra'; import { normalizePath } from '../../src'; @@ -49,7 +49,7 @@ describe('Module Helpers', () => { 'export default defineBackground(() => {})', ); - const entrypoint: GenericEntrypoint = { + const entrypoint: UnlistedScriptEntrypoint = { type: 'unlisted-script', inputPath: project.resolvePath('modules/test/injected.ts'), name: 'injected', diff --git a/packages/wxt/e2e/tests/output-structure.test.ts b/packages/wxt/e2e/tests/output-structure.test.ts index 3b0dc9339..2a691a9fe 100644 --- a/packages/wxt/e2e/tests/output-structure.test.ts +++ b/packages/wxt/e2e/tests/output-structure.test.ts @@ -459,4 +459,104 @@ describe('Output Directory Structure', () => { " `); }); + + describe('globalName option', () => { + it('generates an IIFE with a default name', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/content.js', + `export default defineContentScript({ + matches: ["*://*/*"], + main() {}, + })`, + ); + + await project.build({ vite: () => ({ build: { minify: false } }) }); + + const output = await project.serializeFile( + '.output/chrome-mv3/content-scripts/content.js', + ); + expect(output).toMatch(/^var content\s?=[\s\S]*^content;$/gm); + }); + + it('generates an IIFE with a specific name', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/content.js', + `export default defineContentScript({ + globalName: "MyContentScript", + matches: ["*://*/*"], + main() {}, + })`, + ); + + await project.build({ vite: () => ({ build: { minify: false } }) }); + + const output = await project.serializeFile( + '.output/chrome-mv3/content-scripts/content.js', + ); + expect(output).toMatch( + /^var MyContentScript =[\s\S]*^MyContentScript;$/gm, + ); + }); + + it('generates an IIFE with a specific name provided by a function', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/content.js', + `export default defineContentScript({ + globalName: () => "MyContentScript", + matches: ["*://*/*"], + main() {}, + })`, + ); + + await project.build({ vite: () => ({ build: { minify: false } }) }); + + const output = await project.serializeFile( + '.output/chrome-mv3/content-scripts/content.js', + ); + expect(output).toMatch( + /^var MyContentScript =[\s\S]*^MyContentScript;$/gm, + ); + }); + + it('generates an anonymous IIFE when not minified', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/content.js', + `export default defineContentScript({ + globalName: false, + matches: ["*://*/*"], + main() {}, + })`, + ); + + await project.build({ vite: () => ({ build: { minify: false } }) }); + + const output = await project.serializeFile( + '.output/chrome-mv3/content-scripts/content.js', + ); + expect(output).toMatch(/^\(function\(\) {[\s\S]*^}\)\(\);$/gm); + }); + + it('generates an anonymous IIFE when minified', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/content.js', + `export default defineContentScript({ + globalName: false, + matches: ["*://*/*"], + main() {}, + })`, + ); + + await project.build({ vite: () => ({ build: { minify: true } }) }); + + const output = await project.serializeFile( + '.output/chrome-mv3/content-scripts/content.js', + ); + expect(output).toMatch(/^\(function\(\){[\s\S]*}\)\(\);$/gm); + }); + }); }); diff --git a/packages/wxt/src/core/builders/vite/index.ts b/packages/wxt/src/core/builders/vite/index.ts index 4cbbc07df..986a99bd3 100644 --- a/packages/wxt/src/core/builders/vite/index.ts +++ b/packages/wxt/src/core/builders/vite/index.ts @@ -109,7 +109,7 @@ export async function createViteBuilder( const plugins: NonNullable = [ wxtPlugins.entrypointGroupGlobals(entrypoint), ]; - const iifeReturnValueName = safeVarName(entrypoint.name); + let iifeReturnValueName = safeVarName(entrypoint.name); if ( entrypoint.type === 'content-script-style' || @@ -122,7 +122,17 @@ export async function createViteBuilder( entrypoint.type === 'content-script' || entrypoint.type === 'unlisted-script' ) { - plugins.push(wxtPlugins.iifeFooter(iifeReturnValueName)); + if (typeof entrypoint.options.globalName === 'string') { + iifeReturnValueName = entrypoint.options.globalName; + } else if (typeof entrypoint.options.globalName === 'function') { + iifeReturnValueName = entrypoint.options.globalName(entrypoint); + } + + if (entrypoint.options.globalName === false) { + plugins.push(wxtPlugins.iifeAnonymous(iifeReturnValueName)); + } else { + plugins.push(wxtPlugins.iifeFooter(iifeReturnValueName)); + } } return { diff --git a/packages/wxt/src/core/builders/vite/plugins/iifeAnonymous.ts b/packages/wxt/src/core/builders/vite/plugins/iifeAnonymous.ts new file mode 100644 index 000000000..fe4e0cc28 --- /dev/null +++ b/packages/wxt/src/core/builders/vite/plugins/iifeAnonymous.ts @@ -0,0 +1,17 @@ +import type { Plugin } from 'vite'; + +export function iifeAnonymous(iifeReturnValueName: string): Plugin { + return { + name: 'wxt:iife-anonymous', + generateBundle(_, bundle) { + for (const chunk of Object.values(bundle)) { + if (chunk.type === 'chunk' && chunk.isEntry) { + const namedIIFEPrefix = new RegExp( + `^var ${iifeReturnValueName}\\s*=\\s*(\\(function)`, + ); + chunk.code = chunk.code.replace(namedIIFEPrefix, '$1'); + } + } + }, + }; +} diff --git a/packages/wxt/src/core/builders/vite/plugins/index.ts b/packages/wxt/src/core/builders/vite/plugins/index.ts index 27ea24c82..703b3bb04 100644 --- a/packages/wxt/src/core/builders/vite/plugins/index.ts +++ b/packages/wxt/src/core/builders/vite/plugins/index.ts @@ -14,3 +14,4 @@ export * from './removeEntrypointMainFunction'; export * from './wxtPluginLoader'; export * from './resolveAppConfig'; export * from './iifeFooter'; +export * from './iifeAnonymous'; diff --git a/packages/wxt/src/core/utils/building/find-entrypoints.ts b/packages/wxt/src/core/utils/building/find-entrypoints.ts index f6992fe0d..49c72c54b 100644 --- a/packages/wxt/src/core/utils/building/find-entrypoints.ts +++ b/packages/wxt/src/core/utils/building/find-entrypoints.ts @@ -10,6 +10,7 @@ import { SidepanelEntrypoint, MainWorldContentScriptEntrypointOptions, IsolatedWorldContentScriptEntrypointOptions, + UnlistedScriptEntrypoint, } from '../../../types'; import fs from 'fs-extra'; import { minimatch } from 'minimatch'; @@ -319,7 +320,7 @@ async function getUnlistedPageEntrypoint( async function getUnlistedScriptEntrypoint( { inputPath, name }: EntrypointInfo, options: Record, -): Promise { +): Promise { return { type: 'unlisted-script', name, diff --git a/packages/wxt/src/core/utils/testing/fake-objects.ts b/packages/wxt/src/core/utils/testing/fake-objects.ts index 62b2154d2..c21e03412 100644 --- a/packages/wxt/src/core/utils/testing/fake-objects.ts +++ b/packages/wxt/src/core/utils/testing/fake-objects.ts @@ -21,6 +21,7 @@ import { Wxt, SidepanelEntrypoint, BaseEntrypoint, + UnlistedScriptEntrypoint, } from '../../../types'; import { mock } from 'vitest-mock-extended'; import { vi } from 'vitest'; @@ -99,7 +100,7 @@ export const fakeBackgroundEntrypoint = fakeObjectCreator( ); export const fakeUnlistedScriptEntrypoint = - fakeObjectCreator(() => ({ + fakeObjectCreator(() => ({ type: 'unlisted-script', inputPath: fakeFile('src'), name: faker.string.alpha(), @@ -186,7 +187,6 @@ export const fakeGenericEntrypoint = fakeObjectCreator( 'newtab', 'devtools', 'unlisted-page', - 'unlisted-script', ]), inputPath: fakeFile('src'), name: faker.string.alpha(), diff --git a/packages/wxt/src/types.ts b/packages/wxt/src/types.ts index 04b5306e4..65a4c8e8d 100644 --- a/packages/wxt/src/types.ts +++ b/packages/wxt/src/types.ts @@ -566,7 +566,28 @@ export interface BackgroundEntrypointOptions extends BaseEntrypointOptions { type?: PerBrowserOption<'module'>; } -export interface BaseContentScriptEntrypointOptions extends BaseEntrypointOptions { +export interface BaseScriptEntrypointOptions extends BaseEntrypointOptions { + /** + * The variable name for the IIFE in the output bundle. + * + * This option is relevant for scripts inserted into the page context where the default IIFE + * variable name may conflict with an existing variable on the target page. This applies to content + * scripts with world=MAIN, and others, such as unlisted scripts, that could be dynamically injected + * into the page with a diff --git a/docs/.vitepress/composables/useListExtensionDetails.ts b/docs/.vitepress/composables/useListExtensionDetails.ts index 119236c07..65a73790c 100644 --- a/docs/.vitepress/composables/useListExtensionDetails.ts +++ b/docs/.vitepress/composables/useListExtensionDetails.ts @@ -1,39 +1,58 @@ import { ref } from 'vue'; -export interface ChromeExtension { +export interface Extension { id: string; name: string; iconUrl: string; - weeklyActiveUsers: number; shortDescription: string; storeUrl: string; rating: number | undefined; + users: number; +} + +export interface ExtensionResults { + chrome: Extension[]; + firefox: Extension[]; } const operationName = 'WxtDocsUsedBy'; -const query = `query ${operationName}($ids:[String!]!) { - chromeExtensions(ids: $ids) { +const query = `query ${operationName}($chromeIds: [String!]!, $firefoxIds: [String!]!) { + chromeExtensions(ids: $chromeIds) { id - name - iconUrl - weeklyActiveUsers - shortDescription - storeUrl - rating + ...ExtensionData } + firefoxAddons(ids: $firefoxIds) { + id: slug + ...ExtensionData + } +} + +fragment ExtensionData on Extension { + name + iconUrl + shortDescription + storeUrl + rating + users }`; -export default function (ids: string[]) { - const data = ref(); +export default function (chromeIds: string[], firefoxSlugs: string[]) { + const data = ref(); const err = ref(); const isLoading = ref(true); + if (chromeIds.length === 0 && firefoxSlugs.length === 0) { + data.value = { chrome: [], firefox: [] }; + isLoading.value = false; + return { data, err, isLoading }; + } + fetch('https://queue.wxt.dev/api', { method: 'POST', body: JSON.stringify({ operationName, query, - variables: { ids }, + variables: { chromeIds, firefoxIds: firefoxSlugs }, }), headers: { 'Content-Type': 'application/json', @@ -41,10 +60,11 @@ export default function (ids: string[]) { }) .then(async (res) => { isLoading.value = false; - const { - data: { chromeExtensions }, - } = await res.json(); - data.value = chromeExtensions; + const { data: responseData } = await res.json(); + data.value = { + chrome: responseData.chromeExtensions ?? [], + firefox: responseData.firefoxAddons ?? [], + }; err.value = undefined; }) .catch((error) => { @@ -54,9 +74,5 @@ export default function (ids: string[]) { err.value = error; }); - return { - data, - err, - isLoading, - }; + return { data, err, isLoading }; } From 42c8a02ef9429dedc4ca498f9790170be3b18008 Mon Sep 17 00:00:00 2001 From: Skylar Bolton Date: Mon, 13 Apr 2026 00:33:50 -0400 Subject: [PATCH 105/227] docs: Add 5 Whiteboard Works extensions to the homepage (#2207) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Aaron --- .../.vitepress/components/UsingWxtSection.vue | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/.vitepress/components/UsingWxtSection.vue b/docs/.vitepress/components/UsingWxtSection.vue index 2a0832384..9ffba2599 100644 --- a/docs/.vitepress/components/UsingWxtSection.vue +++ b/docs/.vitepress/components/UsingWxtSection.vue @@ -552,6 +552,31 @@ const extensionEntries: ExtensionEntry[] = [ chromeId: 'pmgehhllikbjmadpenhabejhpemplhmd', firefoxSlug: 'rank-checker', }, + { + // PlayFaster - Enhanced playback speed control for online videos and audio + chromeId: 'fppcbkhpahkbgijkdcpjgjmhpfbmfiih', + firefoxSlug: 'playfaster', + }, + { + // LatTab - Learn Latin With Every New Tab + chromeId: 'eiocjaocpmackhbaffoejkcmnfbdpgpj', + firefoxSlug: 'lattab-learn-latin-new-tabs', + }, + { + // Vim What? - Interactive Vim command reference + chromeId: 'ngbehgnlcdjkbnihgpkgdangbhemidge', + firefoxSlug: 'vim-what', + }, + { + // FRED - Fraud Recognition Easy Detection + chromeId: 'bjdbcabacnlmbpcmiapcdfancfgcakfn', + firefoxSlug: 'fred', + }, + { + // Clear Wisdom - Gems of wisdom from James Clear's 3-2-1 newsletter + chromeId: 'jijfmgoijddfmlcdghopbkdpelbpmjdm', + firefoxSlug: 'clear-wisdom', + }, ]; const chromeIds = extensionEntries.flatMap((e) => From b2a099689f986dd2d5cc7b395320c186220a37c0 Mon Sep 17 00:00:00 2001 From: Sullivan <38718448+Epic-R-R@users.noreply.github.com> Date: Mon, 13 Apr 2026 08:09:21 +0330 Subject: [PATCH 106/227] docs: Added "SoundCloud Enhanced Pro" to the homepage (#2238) Co-authored-by: Aaron --- docs/.vitepress/components/UsingWxtSection.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/.vitepress/components/UsingWxtSection.vue b/docs/.vitepress/components/UsingWxtSection.vue index 9ffba2599..c93215d7f 100644 --- a/docs/.vitepress/components/UsingWxtSection.vue +++ b/docs/.vitepress/components/UsingWxtSection.vue @@ -577,6 +577,10 @@ const extensionEntries: ExtensionEntry[] = [ chromeId: 'jijfmgoijddfmlcdghopbkdpelbpmjdm', firefoxSlug: 'clear-wisdom', }, + { + // Soundcloud Enhanced Pro + chromeId: 'ggplcohodggmdfpopelnpplhgfjclomi', + }, ]; const chromeIds = extensionEntries.flatMap((e) => From de2b38217ab614aa45979a265eddd5fec5c08da0 Mon Sep 17 00:00:00 2001 From: Kamer DINC Date: Mon, 13 Apr 2026 07:42:59 +0300 Subject: [PATCH 107/227] docs: Add AlarmBot extension to the list of extensions (#2251) Co-authored-by: Aaron --- docs/.vitepress/components/UsingWxtSection.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/.vitepress/components/UsingWxtSection.vue b/docs/.vitepress/components/UsingWxtSection.vue index c93215d7f..986d76d1f 100644 --- a/docs/.vitepress/components/UsingWxtSection.vue +++ b/docs/.vitepress/components/UsingWxtSection.vue @@ -581,6 +581,10 @@ const extensionEntries: ExtensionEntry[] = [ // Soundcloud Enhanced Pro chromeId: 'ggplcohodggmdfpopelnpplhgfjclomi', }, + { + // AlarmBot: ULTIMATE Web Monitoring & Smart Price Alerts + chromeId: 'mpckalcodookackleecihhnngdibelif', + }, ]; const chromeIds = extensionEntries.flatMap((e) => From e16052cdc9dcd298386e487bb4cbade03d5110b2 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 13 Apr 2026 00:12:22 -0500 Subject: [PATCH 108/227] docs: Move homepage extensions to separate YAML file (#2255) --- cspell.yml | 1 + .../.vitepress/components/UsingWxtSection.vue | 576 +----------------- docs/.vitepress/config.ts | 13 +- docs/assets/extension-showcase.yml | 435 +++++++++++++ package.json | 6 +- 5 files changed, 457 insertions(+), 574 deletions(-) create mode 100644 docs/assets/extension-showcase.yml diff --git a/cspell.yml b/cspell.yml index 66c463a71..1ff8f06eb 100644 --- a/cspell.yml +++ b/cspell.yml @@ -10,6 +10,7 @@ ignorePaths: - packages/browser/src/gen/** - patches/** - '**/*.snap' + - docs/assets/extension-showcase.yml words: - Aabid - aabidk diff --git a/docs/.vitepress/components/UsingWxtSection.vue b/docs/.vitepress/components/UsingWxtSection.vue index 986d76d1f..8de415fb9 100644 --- a/docs/.vitepress/components/UsingWxtSection.vue +++ b/docs/.vitepress/components/UsingWxtSection.vue @@ -1,11 +1,7 @@