diff --git a/cspell.yml b/cspell.yml index 55febfc9..ddfc0a40 100644 --- a/cspell.yml +++ b/cspell.yml @@ -41,6 +41,7 @@ words: - esbuild - esque - extglob + - favicons - fieldtrials - firefoxdeveloperedition - Florian diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 6b2e9041..fb2818ca 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -224,6 +224,7 @@ export default defineConfig({ menuItem('Storage', 'storage.md'), menuItem('Messaging', 'messaging.md'), menuItem('I18n', 'i18n.md'), + menuItem('Favicons', 'favicons.md'), menuItem('Scripting', 'scripting.md'), menuItem('WXT Modules', 'wxt-modules.md'), menuItem('Frontend Frameworks', 'frontend-frameworks.md'), diff --git a/docs/guide/essentials/favicons.md b/docs/guide/essentials/favicons.md new file mode 100644 index 00000000..524d86f5 --- /dev/null +++ b/docs/guide/essentials/favicons.md @@ -0,0 +1,51 @@ +# Favicons + +[Chrome Docs](https://developer.chrome.com/docs/extensions/how-to/ui/favicons) + +Chromium-based browsers expose cached favicons through the `_favicon/` URL served from your extension. To use it, declare the `favicon` permission in your manifest and call `browser.runtime.getURL` with a `_favicon/` path: + +```ts +// wxt.config.ts +import { defineConfig } from 'wxt'; + +export default defineConfig({ + manifest: { + permissions: ['favicon'], + }, +}); +``` + +```ts +// Any entrypoint +function getFaviconUrl(pageUrl: string, size = 16) { + const url = new URL(browser.runtime.getURL('/_favicon/')); + url.searchParams.set('pageUrl', pageUrl); + url.searchParams.set('size', String(size)); + return url.toString(); +} +``` + +::: warning Chromium only +The favicon API is only available on Chromium-based browsers. Firefox has no equivalent, so `/_favicon/` URLs will not resolve at runtime there. If your extension supports both browsers, gate favicon usage behind `import.meta.env.CHROME` (or similar) and declare the permission per-browser. +::: + +## Usage from a content script + +WXT does **not** add a `web_accessible_resources` entry for `_favicon/*` — not every extension needs one, and adding it unconditionally would expose internals to sites that don't need them. If you want to load a favicon inside a content script (for example, as an ``), add your own entry in `wxt.config.ts`: + +```ts +// wxt.config.ts +import { defineConfig } from 'wxt'; + +export default defineConfig({ + manifest: { + permissions: ['favicon'], + web_accessible_resources: [ + { + resources: ['_favicon/*'], + matches: [''], + }, + ], + }, +}); +``` diff --git a/packages/wxt/e2e/tests/typescript-project.test.ts b/packages/wxt/e2e/tests/typescript-project.test.ts index 9b89ee8e..15de84b8 100644 --- a/packages/wxt/e2e/tests/typescript-project.test.ts +++ b/packages/wxt/e2e/tests/typescript-project.test.ts @@ -420,6 +420,48 @@ describe('TypeScript Project', () => { expect(output).toContain('./example.ts'); }); + it('should type `/_favicon/` paths when the `favicon` permission is declared', async () => { + const project = new TestProject(); + project.addFile('entrypoints/popup.html', ''); + + await project.build({ + manifest: { permissions: ['favicon'] }, + }); + + const pathsOutput = await project.serializeFile('.wxt/types/paths.d.ts'); + const manifest = await project.getOutputManifest(); + + // The generated type must be a template-literal (backticks), not a + // string-literal — otherwise query params (`?pageUrl=...&size=...`) + // would not type-check. + expect(pathsOutput).toMatchInlineSnapshot(` + ".wxt/types/paths.d.ts + ---------------------------------------- + // Generated by wxt + import "wxt/browser"; + + declare module "wxt/browser" { + export type PublicPath = + | "" + | "/" + | "/popup.html" + | \`/_favicon/\${string}\` + type HtmlPublicPath = Extract + export interface WxtRuntime { + getURL(path: PublicPath): string; + getURL(path: \`\${HtmlPublicPath}\${string}\`): string; + } + } + " + `); + + // Per the review on #1570, WXT intentionally does NOT add a + // `web_accessible_resources` entry for `_favicon/*`: that's only + // needed for content-script usage, and users who need it can add + // it themselves. + expect(manifest.web_accessible_resources).toBeUndefined(); + }); + it('should set correct import.meta.env.BROWSER type based on targetBrowsers', async () => { const project = new TestProject(); project.addFile('entrypoints/unlisted.html', ''); diff --git a/packages/wxt/src/builtin-modules/favicon-permission.ts b/packages/wxt/src/builtin-modules/favicon-permission.ts new file mode 100644 index 00000000..bb7b07e8 --- /dev/null +++ b/packages/wxt/src/builtin-modules/favicon-permission.ts @@ -0,0 +1,41 @@ +import { defineWxtModule } from '../modules'; +import type { WxtDirFileEntry } from '../types'; + +/** + * Adds a template-literal type for the `_favicon/` paths served by Chrome's + * favicon API when the `favicon` permission is declared. With this module, + * `browser.runtime.getURL('/_favicon/?pageUrl=...')` type-checks without + * needing a `@ts-expect-error`. + * + * Extensions that load favicons from a content script must still add their own + * `web_accessible_resources` entry — this module intentionally does not touch + * the manifest. See the review thread on #1570 for context. + * + * @see https://developer.chrome.com/docs/extensions/how-to/ui/favicons + */ +export default defineWxtModule({ + name: 'wxt:built-in:favicon-permission', + setup(wxt) { + wxt.hooks.hook('prepare:types', (_, entries) => { + if (!wxt.config.manifest.permissions?.includes('favicon')) return; + + const pathsEntry = entries.find( + (entry): entry is WxtDirFileEntry => + 'path' in entry && entry.path === 'types/paths.d.ts', + ); + if (!pathsEntry) return; + + // The base generator wraps every public path in double quotes, + // producing string-literal union members. Favicon URLs need a + // template-literal type so arbitrary query strings type-check, so + // we splice a `\`/_favicon/\${string}\`` member into the union + // directly. The `HtmlPublicPath` line is a stable anchor emitted + // by generate-wxt-dir.ts:getPathsDeclarationEntry. + const anchor = ' type HtmlPublicPath'; + pathsEntry.text = pathsEntry.text.replace( + anchor, + ' | `/_favicon/${string}`\n' + anchor, + ); + }); + }, +}); diff --git a/packages/wxt/src/builtin-modules/index.ts b/packages/wxt/src/builtin-modules/index.ts index d958b95b..540f72fd 100644 --- a/packages/wxt/src/builtin-modules/index.ts +++ b/packages/wxt/src/builtin-modules/index.ts @@ -1,4 +1,5 @@ import { WxtModule } from '../types'; +import faviconPermission from './favicon-permission'; import unimport from './unimport'; -export const builtinModules: WxtModule[] = [unimport]; +export const builtinModules: WxtModule[] = [unimport, faviconPermission];