feat: auto-discover Firefox theme_icons from public assets (#2242)

Co-authored-by: oferitz <oferitz@Ofers-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Aaron <aaronklinker1@gmail.com>
This commit is contained in:
Ofer Itzhaki
2026-04-11 23:37:50 +03:00
committed by GitHub
parent 0159b4315e
commit 5f799ca23e
4 changed files with 410 additions and 0 deletions
+24
View File
@@ -159,6 +159,30 @@ export default defineConfig({
Alternatively, you can use [`@wxt-dev/auto-icons`](https://www.npmjs.com/package/@wxt-dev/auto-icons) to let WXT generate your icon at the required sizes.
### Firefox `theme_icons`
Firefox supports a [`theme_icons`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/action#theme_icons) field on the toolbar action that swaps between a light and dark variant based on the current browser theme.
When [targeting Firefox](/guide/essentials/target-different-browsers.md), WXT auto-discovers paired light/dark icons in the `public/` directory and attaches them to `action` (MV3) or `browser_action` (MV2). You only need to drop both variants next to your regular icons:
```plaintext
public/
├─ icon-16.png # regular (default_icon)
├─ icon-light-16.png # Firefox light theme
├─ icon-dark-16.png # Firefox dark theme
├─ icon-32.png
├─ icon-light-32.png
└─ icon-dark-32.png
```
A size is only included in `theme_icons` if **both** a light and a dark file are present. The following filename patterns are discovered:
<<< @/../packages/wxt/src/core/utils/theme-icons.ts#snippet
Only `.png` files are discovered today, even though Firefox supports `.svg` - follow [#1120](https://github.com/wxt-dev/wxt/issues/1120) for updates.
If you set `manifest.action.theme_icons` (or `manifest.browser_action.theme_icons`) explicitly in `wxt.config.ts`, WXT will not overwrite it.
## Permissions
> [Chrome docs](https://developer.chrome.com/docs/extensions/reference/permissions/)
@@ -531,6 +531,294 @@ describe('Manifest Utils', () => {
});
});
describe('theme_icons auto-discovery (Firefox)', () => {
const firefoxPopup = () =>
fakePopupEntrypoint({
options: {
// @ts-expect-error: Force undefined instead of the random value
mv2Key: null,
},
outputDir: outDir,
skipped: false,
});
it('should auto-discover paired light/dark icons and attach them to action in mv3', async () => {
const popup = firefoxPopup();
const buildOutput = fakeBuildOutput({
publicAssets: [
{ type: 'asset', fileName: 'icon-light-16.png' },
{ type: 'asset', fileName: 'icon-dark-16.png' },
{ type: 'asset', fileName: 'icon-light-32.png' },
{ type: 'asset', fileName: 'icon-dark-32.png' },
],
});
setFakeWxt({
config: {
browser: 'firefox',
manifestVersion: 3,
outDir,
},
});
const { manifest: actual } = await generateManifest(
[popup],
buildOutput,
);
expect((actual.action as any).theme_icons).toEqual([
{ light: 'icon-light-16.png', dark: 'icon-dark-16.png', size: 16 },
{ light: 'icon-light-32.png', dark: 'icon-dark-32.png', size: 32 },
]);
});
it('should auto-discover and attach theme_icons to browser_action in mv2', async () => {
const popup = firefoxPopup();
const buildOutput = fakeBuildOutput({
publicAssets: [
{ type: 'asset', fileName: 'icon/16-light.png' },
{ type: 'asset', fileName: 'icon/16-dark.png' },
],
});
setFakeWxt({
config: {
browser: 'firefox',
manifestVersion: 2,
outDir,
},
});
const { manifest: actual } = await generateManifest(
[popup],
buildOutput,
);
expect((actual.browser_action as any).theme_icons).toEqual([
{ light: 'icon/16-light.png', dark: 'icon/16-dark.png', size: 16 },
]);
});
it('should support every naming pattern (suffix, prefix, with/without folder, with/without WxH)', async () => {
const popup = firefoxPopup();
const buildOutput = fakeBuildOutput({
publicAssets: [
// suffix / no folder
{ type: 'asset', fileName: 'icon-light-16.png' },
{ type: 'asset', fileName: 'icon-dark-16.png' },
// suffix / WxH / no folder
{ type: 'asset', fileName: 'icon-light-24x24.png' },
{ type: 'asset', fileName: 'icon-dark-24x24.png' },
// prefix / no folder
{ type: 'asset', fileName: 'icon-32-light.png' },
{ type: 'asset', fileName: 'icon-32-dark.png' },
// suffix / folder
{ type: 'asset', fileName: 'icon/light-48.png' },
{ type: 'asset', fileName: 'icon/dark-48.png' },
// prefix / folder
{ type: 'asset', fileName: 'icons/64-light.png' },
{ type: 'asset', fileName: 'icons/64-dark.png' },
// prefix / WxH / folder
{ type: 'asset', fileName: 'icon/128x128-light.png' },
{ type: 'asset', fileName: 'icon/128x128-dark.png' },
],
});
setFakeWxt({
config: {
browser: 'firefox',
manifestVersion: 3,
outDir,
},
});
const { manifest: actual } = await generateManifest(
[popup],
buildOutput,
);
expect((actual.action as any).theme_icons).toEqual([
{ light: 'icon-light-16.png', dark: 'icon-dark-16.png', size: 16 },
{
light: 'icon-light-24x24.png',
dark: 'icon-dark-24x24.png',
size: 24,
},
{ light: 'icon-32-light.png', dark: 'icon-32-dark.png', size: 32 },
{ light: 'icon/light-48.png', dark: 'icon/dark-48.png', size: 48 },
{ light: 'icons/64-light.png', dark: 'icons/64-dark.png', size: 64 },
{
light: 'icon/128x128-light.png',
dark: 'icon/128x128-dark.png',
size: 128,
},
]);
});
it('should skip sizes that are missing a light or dark pair and log a warning', async () => {
const popup = firefoxPopup();
const buildOutput = fakeBuildOutput({
publicAssets: [
// Complete pair — should be kept
{ type: 'asset', fileName: 'icon-light-16.png' },
{ type: 'asset', fileName: 'icon-dark-16.png' },
// Only light, no dark — should be dropped + warn
{ type: 'asset', fileName: 'icon-light-32.png' },
// Only dark, no light — should be dropped + warn
{ type: 'asset', fileName: 'icon-dark-48.png' },
],
});
setFakeWxt({
config: {
browser: 'firefox',
manifestVersion: 3,
outDir,
},
});
const { manifest: actual } = await generateManifest(
[popup],
buildOutput,
);
expect((actual.action as any).theme_icons).toEqual([
{ light: 'icon-light-16.png', dark: 'icon-dark-16.png', size: 16 },
]);
expect(wxt.logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'Skipping theme icon size 32: found light variant',
),
);
expect(wxt.logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'Skipping theme icon size 48: found dark variant',
),
);
});
it('should warn when different naming patterns resolve to the same (size, variant)', async () => {
const popup = firefoxPopup();
const buildOutput = fakeBuildOutput({
publicAssets: [
// Both of these match the light/16 slot via different regexes.
// WXT should keep the first and warn about the second.
{ type: 'asset', fileName: 'icon-light-16.png' },
{ type: 'asset', fileName: 'icon/light-16.png' },
{ type: 'asset', fileName: 'icon-dark-16.png' },
],
});
setFakeWxt({
config: {
browser: 'firefox',
manifestVersion: 3,
outDir,
},
});
const { manifest: actual } = await generateManifest(
[popup],
buildOutput,
);
expect((actual.action as any).theme_icons).toEqual([
{ light: 'icon-light-16.png', dark: 'icon-dark-16.png', size: 16 },
]);
expect(wxt.logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'Multiple theme icon files matched size 16 variant "light"',
),
);
});
it('should not auto-discover theme_icons for non-Firefox browsers', async () => {
const popup = firefoxPopup();
const buildOutput = fakeBuildOutput({
publicAssets: [
{ type: 'asset', fileName: 'icon-light-16.png' },
{ type: 'asset', fileName: 'icon-dark-16.png' },
],
});
setFakeWxt({
config: {
browser: 'chrome',
manifestVersion: 3,
outDir,
},
});
const { manifest: actual } = await generateManifest(
[popup],
buildOutput,
);
expect((actual.action as any).theme_icons).toBeUndefined();
});
it('should respect user-provided theme_icons and not overwrite them', async () => {
const userThemeIcons = [
{ light: 'custom-light.png', dark: 'custom-dark.png', size: 16 },
];
const popup = fakePopupEntrypoint({
options: {
// @ts-expect-error: Force undefined
mv2Key: null,
themeIcons: userThemeIcons,
},
outputDir: outDir,
skipped: false,
});
const buildOutput = fakeBuildOutput({
publicAssets: [
{ type: 'asset', fileName: 'icon-light-16.png' },
{ type: 'asset', fileName: 'icon-dark-16.png' },
{ type: 'asset', fileName: 'icon-light-32.png' },
{ type: 'asset', fileName: 'icon-dark-32.png' },
],
});
setFakeWxt({
config: {
browser: 'firefox',
manifestVersion: 3,
outDir,
},
});
const { manifest: actual } = await generateManifest(
[popup],
buildOutput,
);
expect((actual.action as any).theme_icons).toEqual(userThemeIcons);
});
it('should not attach theme_icons when there is no action at all', async () => {
// A background entrypoint with no popup means no action is
// declared, so we have nothing to attach theme_icons to.
const background = fakeBackgroundEntrypoint({
outputDir: outDir,
skipped: false,
});
const buildOutput = fakeBuildOutput({
publicAssets: [
{ type: 'asset', fileName: 'icon-light-16.png' },
{ type: 'asset', fileName: 'icon-dark-16.png' },
],
});
setFakeWxt({
config: {
browser: 'firefox',
manifestVersion: 3,
outDir,
},
});
const { manifest: actual } = await generateManifest(
[background],
buildOutput,
);
expect(actual.action).toBeUndefined();
expect(actual.browser_action).toBeUndefined();
});
});
describe('content_scripts', () => {
it('should group content scripts and styles together based on their manifest properties', async () => {
const cs1: ContentScriptEntrypoint = {
+5
View File
@@ -20,6 +20,7 @@ import { normalizePath } from './paths';
import { writeFileIfDifferent } from './fs';
import defu from 'defu';
import { wxt } from '../wxt';
import { addDiscoveredThemeIcons } from './theme-icons';
import { ManifestV3WebAccessibleResource } from './types';
import type { Browser } from '@wxt-dev/browser';
@@ -116,6 +117,10 @@ export async function generateManifest(
addEntrypoints(manifest, entrypoints, buildOutput);
if (wxt.config.browser === 'firefox') {
addDiscoveredThemeIcons(manifest, buildOutput);
}
if (wxt.config.command === 'serve') addDevModeCsp(manifest);
if (wxt.config.command === 'serve') addDevModePermissions(manifest);
@@ -0,0 +1,93 @@
import type { Browser } from '@wxt-dev/browser';
import type { BuildOutput, ThemeIcon } from '../../types';
import { normalizePath } from './paths';
import { wxt } from '../wxt';
/**
* Firefox only.
*
* If the manifest has an `action` (MV3) or `browser_action` (MV2) and the user
* has not already set `theme_icons` on it, discover light/dark icon pairs from
* the public assets and attach them.
*/
export function addDiscoveredThemeIcons(
manifest: Browser.runtime.Manifest,
buildOutput: Omit<BuildOutput, 'manifest'>,
): void {
const action = manifest.action ?? manifest.browser_action;
if (action == null) return;
// Respect explicit user config or popup entrypoint option.
if ((action as { theme_icons?: ThemeIcon[] }).theme_icons != null) return;
const themeIcons = discoverThemeIcons(buildOutput);
if (themeIcons == null) return;
(action as { theme_icons?: ThemeIcon[] }).theme_icons = themeIcons;
}
/**
* Scan `publicAssets` for paired `-light`/`-dark` icon files and return the
* sizes where both variants exist, sorted ascending. Returns `undefined` if no
* complete pairs are found so callers can short-circuit.
*/
export function discoverThemeIcons(
buildOutput: Omit<BuildOutput, 'manifest'>,
): ThemeIcon[] | undefined {
const bySize = new Map<number, { light?: string; dark?: string }>();
for (const asset of buildOutput.publicAssets) {
for (const regex of themeIconRegex) {
const match = asset.fileName.match(regex);
if (match?.groups == null) continue;
const size = Number(match.groups.size);
const variant = match.groups.variant as 'light' | 'dark';
const entry = bySize.get(size) ?? {};
const incoming = normalizePath(asset.fileName);
const existing = entry[variant];
if (existing != null && existing !== incoming) {
wxt.logger.warn(
`Multiple theme icon files matched size ${size} variant "${variant}": keeping "${existing}", ignoring "${incoming}". Use a single naming pattern per (size, variant).`,
);
break;
}
entry[variant] = incoming;
bySize.set(size, entry);
break;
}
}
const pairs: ThemeIcon[] = [];
for (const [size, entry] of bySize) {
if (entry.light != null && entry.dark != null) {
pairs.push({ light: entry.light, dark: entry.dark, size });
continue;
}
const present = entry.light != null ? 'light' : 'dark';
const missing = entry.light != null ? 'dark' : 'light';
const file = entry.light ?? entry.dark;
wxt.logger.warn(
`Skipping theme icon size ${size}: found ${present} variant ("${file}") but no matching ${missing} variant. Add the missing file to include this size in theme_icons.`,
);
}
pairs.sort((a, b) => a.size - b.size);
return pairs.length > 0 ? pairs : undefined;
}
// prettier-ignore
// #region snippet
const themeIconRegex = [
/^icon-(?<variant>light|dark)-(?<size>[0-9]+)\.png$/, // icon-light-16.png
/^icon-(?<variant>light|dark)-(?<size>[0-9]+)x[0-9]+\.png$/, // icon-light-16x16.png
/^icon-(?<size>[0-9]+)-(?<variant>light|dark)\.png$/, // icon-16-light.png
/^icon-(?<size>[0-9]+)x[0-9]+-(?<variant>light|dark)\.png$/, // icon-16x16-light.png
/^icons?[/\\](?<variant>light|dark)-(?<size>[0-9]+)\.png$/, // icon/light-16.png | icons/light-16.png
/^icons?[/\\](?<variant>light|dark)-(?<size>[0-9]+)x[0-9]+\.png$/, // icon/light-16x16.png
/^icons?[/\\](?<size>[0-9]+)-(?<variant>light|dark)\.png$/, // icon/16-light.png
/^icons?[/\\](?<size>[0-9]+)x[0-9]+-(?<variant>light|dark)\.png$/, // icon/16x16-light.png
];
// #endregion snippet