From f1985bb91134ad973aa7ddd2fe574d0a63b4dbcc Mon Sep 17 00:00:00 2001 From: silverfish2525 Date: Tue, 30 Jun 2026 16:55:15 +0530 Subject: [PATCH] feat: add @wxt-dev/entrypoint-refs package --- .github/workflows/release.yml | 1 + bun.lock | 18 + packages/entrypoint-refs/README.md | 96 ++++++ packages/entrypoint-refs/package.json | 49 +++ .../src/__tests__/index.test.ts | 315 ++++++++++++++++++ .../src/__tests__/setup.test.ts | 122 +++++++ packages/entrypoint-refs/src/index.ts | 27 ++ packages/entrypoint-refs/src/internal.ts | 85 +++++ packages/entrypoint-refs/tsconfig.json | 4 + packages/entrypoint-refs/vitest.config.ts | 8 + packages/wxt/src/core/utils/index.ts | 1 + packages/wxt/src/types.ts | 9 + 12 files changed, 735 insertions(+) create mode 100644 packages/entrypoint-refs/README.md create mode 100644 packages/entrypoint-refs/package.json create mode 100644 packages/entrypoint-refs/src/__tests__/index.test.ts create mode 100644 packages/entrypoint-refs/src/__tests__/setup.test.ts create mode 100644 packages/entrypoint-refs/src/index.ts create mode 100644 packages/entrypoint-refs/src/internal.ts create mode 100644 packages/entrypoint-refs/tsconfig.json create mode 100644 packages/entrypoint-refs/vitest.config.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a87f8e6f..583715579 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,7 @@ on: options: - analytics - auto-icons + - entrypoint-refs - i18n - is-background - module-react diff --git a/bun.lock b/bun.lock index de6c4f31f..139dd307e 100644 --- a/bun.lock +++ b/bun.lock @@ -96,6 +96,22 @@ "vitest": "catalog:", }, }, + "packages/entrypoint-refs": { + "name": "@wxt-dev/entrypoint-refs", + "version": "0.1.0", + "devDependencies": { + "@aklinker1/buildc": "catalog:", + "oxlint": "catalog:", + "publint": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + "wxt": "workspace:*", + }, + "peerDependencies": { + "wxt": ">=0.21.0", + }, + }, "packages/i18n": { "name": "@wxt-dev/i18n", "version": "0.2.5", @@ -1237,6 +1253,8 @@ "@wxt-dev/browser": ["@wxt-dev/browser@workspace:packages/browser"], + "@wxt-dev/entrypoint-refs": ["@wxt-dev/entrypoint-refs@workspace:packages/entrypoint-refs"], + "@wxt-dev/i18n": ["@wxt-dev/i18n@workspace:packages/i18n"], "@wxt-dev/is-background": ["@wxt-dev/is-background@workspace:packages/is-background"], diff --git a/packages/entrypoint-refs/README.md b/packages/entrypoint-refs/README.md new file mode 100644 index 000000000..9b8c8bb59 --- /dev/null +++ b/packages/entrypoint-refs/README.md @@ -0,0 +1,96 @@ +# @wxt-dev/entrypoint-refs + +WXT module that generates typed constants for every entrypoint's output bundle path. + +## Install + +```ts +// wxt.config.ts +import { defineConfig } from 'wxt'; + +export default defineConfig({ + modules: ['@wxt-dev/entrypoint-refs'], +}); +``` + +## Usage + +After running `wxt prepare` (or starting `wxt dev`), WXT generates `.wxt/entrypoints.ts` with one exported constant per entrypoint: + +```ts +// .wxt/entrypoints.ts (auto-generated, do not edit) +export const ENTRYPOINT_POPUP = 'popup.html'; +export const ENTRYPOINT_BACKGROUND = 'background.js'; +export const ENTRYPOINT_OVERLAY = 'content-scripts/overlay.js'; +export const ENTRYPOINT_OVERLAY_CSS = 'content-scripts/overlay.css'; +``` + +(The `_CSS` constant only exists when the content script has a sibling stylesheet — see [Content scripts with CSS](#content-scripts-with-css) below.) + +Import them via the `#entrypoints` alias: + +```ts +// entrypoints/background.ts +import { ENTRYPOINT_OVERLAY, ENTRYPOINT_OVERLAY_CSS } from '#entrypoints'; + +browser.scripting.registerContentScripts([ + { + id: 'overlay', + js: [ENTRYPOINT_OVERLAY], + css: [ENTRYPOINT_OVERLAY_CSS], + matches: [''], + }, +]); +``` + +If you later rename `entrypoints/overlay.content.ts` to `entrypoints/badge.content.ts`, the import breaks at compile time instead of at runtime. + +## Custom refs + +By default the constant name comes from the entrypoint filename. To give an entrypoint a stable identifier that survives renames, set `ref` in its options: + +```ts +// entrypoints/overlay.content.ts +export default defineContentScript({ + ref: 'OVERLAY', + matches: [''], + main() { + /* ... */ + }, +}); +``` + +Generates: + +```ts +export const ENTRYPOINT_OVERLAY = 'content-scripts/overlay.js'; +``` + +Rename the source file to `entrypoints/badge.content.ts` and the constant stays `ENTRYPOINT_OVERLAY`. + +## Content scripts with CSS + +When a content script has a sibling stylesheet that WXT picks up as a `content-script-style` entrypoint (file-pair like `entrypoints/overlay.content.ts` + `entrypoints/overlay.content.css`, or directory-form `entrypoints/overlay.content/index.ts` + `entrypoints/overlay.content/*.css`), a sibling `_CSS` constant is generated automatically: + +```ts +export const ENTRYPOINT_OVERLAY = 'content-scripts/overlay.js'; +export const ENTRYPOINT_OVERLAY_CSS = 'content-scripts/overlay.css'; +``` + +Pass both to `registerContentScripts`. Content scripts with no CSS get only the `.js` constant. + +## Constant names + +| Source | Generated constant | +| ---------------------------------------- | ----------------------- | +| `entrypoints/popup.html` | `ENTRYPOINT_POPUP` | +| `entrypoints/background.ts` | `ENTRYPOINT_BACKGROUND` | +| `entrypoints/overlay.content.ts` | `ENTRYPOINT_OVERLAY` | +| `entrypoints/my-page.html` | `ENTRYPOINT_MY_PAGE` | +| `defineContentScript({ ref: 'CUSTOM' })` | `ENTRYPOINT_CUSTOM` | + +Non-alphanumeric characters collapse into `_`. + +## Why + +Dynamic APIs like `browser.scripting.registerContentScripts()` and `browser.runtime.getURL()` need the post-bundle path as a string — and a hardcoded `'content-scripts/overlay.js'` keeps compiling after you rename the source file, only to fail at runtime. With this module the path is a constant the type-checker sees, so a rename forces an import update. diff --git a/packages/entrypoint-refs/package.json b/packages/entrypoint-refs/package.json new file mode 100644 index 000000000..4f2b63bc7 --- /dev/null +++ b/packages/entrypoint-refs/package.json @@ -0,0 +1,49 @@ +{ + "name": "@wxt-dev/entrypoint-refs", + "description": "WXT module that generates typed constants for all your extension's entrypoint bundle paths", + "repository": { + "type": "git", + "url": "git+https://github.com/wxt-dev/wxt.git", + "directory": "packages/entrypoint-refs" + }, + "homepage": "https://github.com/wxt-dev/wxt/blob/main/packages/entrypoint-refs/README.md", + "keywords": [ + "wxt", + "module", + "entrypoints", + "browser-extension" + ], + "license": "MIT", + "version": "0.1.0", + "type": "module", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "buildc -- tsdown", + "check": "bun run build && check", + "test": "buildc --deps-only -- vitest", + "test:coverage": "bun run test run --coverage", + "prepack": "bun run build" + }, + "peerDependencies": { + "wxt": ">=0.21.0" + }, + "devDependencies": { + "@aklinker1/buildc": "catalog:", + "oxlint": "catalog:", + "publint": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + "wxt": "workspace:*" + } +} diff --git a/packages/entrypoint-refs/src/__tests__/index.test.ts b/packages/entrypoint-refs/src/__tests__/index.test.ts new file mode 100644 index 000000000..3c7bd0ad4 --- /dev/null +++ b/packages/entrypoint-refs/src/__tests__/index.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect } from 'vitest'; +import { resolve } from 'node:path'; +import type { + Entrypoint, + ResolvedPerBrowserOptions, + BaseEntrypointOptions, +} from 'wxt'; +import { + refToConstName, + getBundleExt, + getRef, + buildEntrypointsFile, +} from '../internal'; + +const ROOT = resolve('/wxt/.output/chrome-mv3'); + +function fakeEntrypoint( + type: Entrypoint['type'], + name: string, + outputDir = ROOT, + options: Partial> = {}, +): Entrypoint { + return { + type, + name, + inputPath: `/wxt/entrypoints/${name}`, + outputDir, + options, + skipped: false, + } as unknown as Entrypoint; +} + +describe('refToConstName', () => { + it('handles simple names', () => { + expect(refToConstName('popup')).toBe('ENTRYPOINT_POPUP'); + expect(refToConstName('background')).toBe('ENTRYPOINT_BACKGROUND'); + expect(refToConstName('newtab')).toBe('ENTRYPOINT_NEWTAB'); + }); + + it('converts kebab-case to UPPER_SNAKE', () => { + expect(refToConstName('my-content-script')).toBe( + 'ENTRYPOINT_MY_CONTENT_SCRIPT', + ); + }); + + it('handles already-uppercase input', () => { + expect(refToConstName('POPUP')).toBe('ENTRYPOINT_POPUP'); + }); + + it('strips leading/trailing separators', () => { + expect(refToConstName('-popup-')).toBe('ENTRYPOINT_POPUP'); + }); + + it('collapses consecutive separators', () => { + expect(refToConstName('my--script')).toBe('ENTRYPOINT_MY_SCRIPT'); + }); + + it('handles dots and underscores', () => { + expect(refToConstName('my.script_v2')).toBe('ENTRYPOINT_MY_SCRIPT_V2'); + }); +}); + +// ─── getBundleExt ─────────────────────────────────────────────────────────── + +describe('getBundleExt', () => { + it.each([ + 'popup', + 'options', + 'sidepanel', + 'newtab', + 'history', + 'bookmarks', + 'devtools', + 'sandbox', + 'unlisted-page', + ] as const)('returns .html for %s', (type) => { + expect(getBundleExt(fakeEntrypoint(type, 'x'))).toBe('.html'); + }); + + it.each(['content-script-style', 'unlisted-style'] as const)( + 'returns .css for %s', + (type) => { + expect(getBundleExt(fakeEntrypoint(type, 'x'))).toBe('.css'); + }, + ); + + it.each(['background', 'content-script', 'unlisted-script'] as const)( + 'returns .js for %s', + (type) => { + expect(getBundleExt(fakeEntrypoint(type, 'x'))).toBe('.js'); + }, + ); +}); + +// ─── buildEntrypointsFile ─────────────────────────────────────────────────── + +describe('getRef', () => { + it('falls back to the entrypoint name when no ref is set', () => { + expect(getRef(fakeEntrypoint('popup', 'popup'))).toBe('popup'); + }); + + it('uses options.ref when set', () => { + expect( + getRef( + fakeEntrypoint('content-script', 'overlay', ROOT, { ref: 'OVERLAY' }), + ), + ).toBe('OVERLAY'); + }); + + it('treats an empty-string ref as fallback to name (falsy-coerced)', () => { + // || coerces empty string to falsy, so name is used instead. + expect( + getRef(fakeEntrypoint('content-script', 'overlay', ROOT, { ref: '' })), + ).toBe('overlay'); + }); +}); + +describe('buildEntrypointsFile', () => { + it('emits one constant per entrypoint with the correct extension', () => { + const entries: Entrypoint[] = [ + fakeEntrypoint('popup', 'popup'), + fakeEntrypoint('background', 'background'), + fakeEntrypoint( + 'content-script', + 'content', + resolve(ROOT, 'content-scripts'), + ), + ]; + const out = buildEntrypointsFile(entries, ROOT); + + expect(out).toContain('export const ENTRYPOINT_POPUP = "popup.html";'); + expect(out).toContain( + 'export const ENTRYPOINT_BACKGROUND = "background.js";', + ); + expect(out).toContain( + 'export const ENTRYPOINT_CONTENT = "content-scripts/content.js";', + ); + }); + + it('disambiguates collisions instead of overwriting', () => { + const entries: Entrypoint[] = [ + fakeEntrypoint('content-script', 'my-script'), + // Different filename, same UPPER_SNAKE form ("my_script" → "MY_SCRIPT") + fakeEntrypoint('unlisted-script', 'my_script'), + ]; + const out = buildEntrypointsFile(entries, ROOT); + const exports = out.match(/export const \w+/g) ?? []; + expect(exports).toHaveLength(2); + expect(new Set(exports).size).toBe(2); + }); + + it('disambiguates same-type collisions with numeric suffixes', () => { + const entries: Entrypoint[] = [ + fakeEntrypoint('content-script', 'foo'), + // Same type, normalizes to same UPPER_SNAKE constant. + fakeEntrypoint('content-script', 'FOO'), + fakeEntrypoint('content-script', 'F-O-O'), + ]; + const out = buildEntrypointsFile(entries, ROOT); + const exports = out.match(/export const \w+/g) ?? []; + expect(exports).toHaveLength(3); + expect(new Set(exports).size).toBe(3); + }); + + it('uses options.ref when present', () => { + const entries: Entrypoint[] = [ + fakeEntrypoint( + 'content-script', + 'overlay', + resolve(ROOT, 'content-scripts'), + { + ref: 'OVERLAY', + }, + ), + ]; + const out = buildEntrypointsFile(entries, ROOT); + expect(out).toContain( + 'export const ENTRYPOINT_OVERLAY = "content-scripts/overlay.js";', + ); + }); + + it('a custom ref survives a source-file rename', () => { + const before = buildEntrypointsFile( + [ + fakeEntrypoint( + 'content-script', + 'overlay', + resolve(ROOT, 'content-scripts'), + { + ref: 'OVERLAY', + }, + ), + ], + ROOT, + ); + const after = buildEntrypointsFile( + [ + fakeEntrypoint( + 'content-script', + 'badge', + resolve(ROOT, 'content-scripts'), + { + ref: 'OVERLAY', + }, + ), + ], + ROOT, + ); + const beforeName = before.match(/ENTRYPOINT_\w+/)?.[0]; + const afterName = after.match(/ENTRYPOINT_\w+/)?.[0]; + expect(beforeName).toBe('ENTRYPOINT_OVERLAY'); + expect(afterName).toBe('ENTRYPOINT_OVERLAY'); + }); + + it('emits a _CSS companion constant when content-script has a style sibling', () => { + const entries: Entrypoint[] = [ + fakeEntrypoint( + 'content-script', + 'overlay', + resolve(ROOT, 'content-scripts'), + ), + fakeEntrypoint( + 'content-script-style', + 'overlay', + resolve(ROOT, 'content-scripts'), + ), + ]; + const out = buildEntrypointsFile(entries, ROOT); + expect(out).toContain( + 'export const ENTRYPOINT_OVERLAY = "content-scripts/overlay.js";', + ); + expect(out).toContain( + 'export const ENTRYPOINT_OVERLAY_CSS = "content-scripts/overlay.css";', + ); + }); + + it('CSS companion follows the content-script ref, not its filename', () => { + const entries: Entrypoint[] = [ + fakeEntrypoint( + 'content-script', + 'overlay', + resolve(ROOT, 'content-scripts'), + { ref: 'CUSTOM' }, + ), + fakeEntrypoint( + 'content-script-style', + 'overlay', + resolve(ROOT, 'content-scripts'), + ), + ]; + const out = buildEntrypointsFile(entries, ROOT); + expect(out).toContain( + 'export const ENTRYPOINT_CUSTOM = "content-scripts/overlay.js";', + ); + expect(out).toContain( + 'export const ENTRYPOINT_CUSTOM_CSS = "content-scripts/overlay.css";', + ); + }); + + it('does not emit _CSS when content-script has no style sibling', () => { + const entries: Entrypoint[] = [ + fakeEntrypoint( + 'content-script', + 'overlay', + resolve(ROOT, 'content-scripts'), + ), + ]; + const out = buildEntrypointsFile(entries, ROOT); + expect(out).not.toContain('CSS'); + }); + + it('emits a .css constant for a standalone content-script-style entry', () => { + const entries: Entrypoint[] = [ + fakeEntrypoint( + 'content-script-style', + 'theme', + resolve(ROOT, 'content-scripts'), + ), + ]; + const out = buildEntrypointsFile(entries, ROOT); + expect(out).toContain( + 'export const ENTRYPOINT_THEME = "content-scripts/theme.css";', + ); + }); + + it('returns a header-only file for an empty entrypoints array', () => { + const out = buildEntrypointsFile([], ROOT); + expect(out).toBe('// Generated by @wxt-dev/entrypoint-refs\n\n'); + }); + + it('starts and ends with the expected markers', () => { + const out = buildEntrypointsFile([fakeEntrypoint('popup', 'popup')], ROOT); + expect(out.startsWith('// Generated by @wxt-dev/entrypoint-refs\n')).toBe( + true, + ); + expect(out.endsWith('\n')).toBe(true); + }); + + it('emits valid TypeScript (no unterminated strings, balanced lines)', () => { + const entries: Entrypoint[] = [ + fakeEntrypoint('popup', 'popup'), + fakeEntrypoint('options', 'options'), + fakeEntrypoint('content-script-style', 'styles'), + ]; + const out = buildEntrypointsFile(entries, ROOT); + // Every export line should end with `;` and contain a quoted string. + const exportLines = out + .split('\n') + .filter((l) => l.startsWith('export const')); + expect(exportLines).toHaveLength(3); + for (const line of exportLines) { + expect(line).toMatch(/^export const \w+ = ".+";$/); + } + }); +}); diff --git a/packages/entrypoint-refs/src/__tests__/setup.test.ts b/packages/entrypoint-refs/src/__tests__/setup.test.ts new file mode 100644 index 000000000..f398b050d --- /dev/null +++ b/packages/entrypoint-refs/src/__tests__/setup.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { resolve } from 'node:path'; +import type { Wxt, Entrypoint, WxtDirEntry } from 'wxt'; +import module from '../index'; + +const FAKE_WXT_DIR = '/fake/.wxt'; +const FAKE_OUT_DIR = '/fake/.output/chrome-mv3'; +const FAKE_ROOT = '/fake'; + +interface MockWxt { + config: { + wxtDir: string; + outDir: string; + root: string; + alias: Record; + }; + logger: { warn: Mock }; + hooks: { hook: Mock }; +} + +function makeMockWxt(): MockWxt { + return { + config: { + wxtDir: FAKE_WXT_DIR, + outDir: FAKE_OUT_DIR, + root: FAKE_ROOT, + alias: {}, + }, + logger: { warn: vi.fn() }, + hooks: { hook: vi.fn() }, + }; +} + +function getHook(wxt: MockWxt, name: string): (...args: unknown[]) => unknown { + const call = wxt.hooks.hook.mock.calls.find((c) => c[0] === name); + expect(call, `hook not registered: ${name}`).toBeDefined(); + return call![1]; +} + +function fakeEp( + type: Entrypoint['type'], + name: string, + outputDir = FAKE_OUT_DIR, +): Entrypoint { + return { + type, + name, + inputPath: `/fake/entrypoints/${name}`, + outputDir, + options: {}, + skipped: false, + } as unknown as Entrypoint; +} + +describe('module setup', () => { + it('registers #entrypoints alias on config:resolved', async () => { + const wxt = makeMockWxt(); + await module.setup!(wxt as unknown as Wxt); + + const onConfigResolved = getHook(wxt, 'config:resolved'); + await onConfigResolved(wxt); + + expect(wxt.config.alias['#entrypoints']).toBe( + resolve(FAKE_WXT_DIR, 'entrypoints.ts'), + ); + }); + + it('prepare:types receives a WxtDirFileEntry with generated content', async () => { + const wxt = makeMockWxt(); + await module.setup!(wxt as unknown as Wxt); + + const onResolved = getHook(wxt, 'entrypoints:resolved'); + await onResolved(wxt, [ + fakeEp('popup', 'popup'), + fakeEp('background', 'background'), + ]); + + const entries: WxtDirEntry[] = []; + const onPrepareTypes = getHook(wxt, 'prepare:types'); + await onPrepareTypes(wxt, entries); + + expect(entries).toHaveLength(1); + const entry = entries[0] as { path: string; text: string }; + expect(entry.path).toBe('entrypoints.ts'); + expect(entry.text).toContain('ENTRYPOINT_POPUP'); + expect(entry.text).toContain('ENTRYPOINT_BACKGROUND'); + }); + + it('skipped entrypoints are excluded from the generated file', async () => { + const wxt = makeMockWxt(); + await module.setup!(wxt as unknown as Wxt); + + const onResolved = getHook(wxt, 'entrypoints:resolved'); + await onResolved(wxt, [ + fakeEp('popup', 'popup'), + { ...fakeEp('content-script', 'overlay'), skipped: true }, + ]); + + const entries: WxtDirEntry[] = []; + await getHook(wxt, 'prepare:types')(wxt, entries); + + const text = (entries[0] as { text: string }).text; + expect(text).toContain('ENTRYPOINT_POPUP'); + expect(text).not.toContain('ENTRYPOINT_OVERLAY'); + }); + + it('latest entrypoints:resolved wins when prepare:types fires', async () => { + const wxt = makeMockWxt(); + await module.setup!(wxt as unknown as Wxt); + + const onResolved = getHook(wxt, 'entrypoints:resolved'); + await onResolved(wxt, [fakeEp('popup', 'popup')]); + await onResolved(wxt, [fakeEp('background', 'background')]); + + const entries: WxtDirEntry[] = []; + await getHook(wxt, 'prepare:types')(wxt, entries); + + const text = (entries[0] as { text: string }).text; + expect(text).toContain('ENTRYPOINT_BACKGROUND'); + expect(text).not.toContain('ENTRYPOINT_POPUP'); + }); +}); diff --git a/packages/entrypoint-refs/src/index.ts b/packages/entrypoint-refs/src/index.ts new file mode 100644 index 000000000..9d5c8e35e --- /dev/null +++ b/packages/entrypoint-refs/src/index.ts @@ -0,0 +1,27 @@ +import 'wxt'; +import { defineWxtModule, addAlias } from 'wxt/modules'; +import { resolve } from 'node:path'; +import type { Entrypoint } from 'wxt'; +import { buildEntrypointsFile } from './internal'; + +export default defineWxtModule({ + name: '@wxt-dev/entrypoint-refs', + setup(wxt) { + const entrypointsFilePath = resolve(wxt.config.wxtDir, 'entrypoints.ts'); + + addAlias(wxt, '#entrypoints', entrypointsFilePath); + + let captured: Entrypoint[] = []; + + wxt.hooks.hook('entrypoints:resolved', (_, entrypoints) => { + captured = entrypoints.filter((e) => !e.skipped); + }); + + wxt.hooks.hook('prepare:types', (_, entries) => { + entries.push({ + path: 'entrypoints.ts', + text: buildEntrypointsFile(captured, wxt.config.outDir), + }); + }); + }, +}); diff --git a/packages/entrypoint-refs/src/internal.ts b/packages/entrypoint-refs/src/internal.ts new file mode 100644 index 000000000..cc1a1d741 --- /dev/null +++ b/packages/entrypoint-refs/src/internal.ts @@ -0,0 +1,85 @@ +import type { Entrypoint } from 'wxt'; +import { getEntrypointBundlePath } from 'wxt'; + +/** + * Build-time helpers for `@wxt-dev/entrypoint-refs`. Not part of the public API + * — kept here so the unit tests can exercise them in isolation. Anything + * exported from this file is bundled into `dist/index.mjs` but reachable only + * to code inside this package. + */ + +export function refToConstName(ref: string): string { + return ( + 'ENTRYPOINT_' + + ref + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + ); +} + +export function getBundleExt(entry: Entrypoint): '.html' | '.css' | '.js' { + switch (entry.type) { + case 'sandbox': + case 'bookmarks': + case 'history': + case 'newtab': + case 'devtools': + case 'unlisted-page': + case 'popup': + case 'options': + case 'sidepanel': + return '.html'; + case 'content-script-style': + case 'unlisted-style': + return '.css'; + default: + return '.js'; + } +} + +export function getRef(entry: Entrypoint): string { + return entry.options.ref || entry.name; +} + +export function buildEntrypointsFile( + entrypoints: Entrypoint[], + outDir: string, +): string { + const lines = ['// Generated by @wxt-dev/entrypoint-refs', '']; + + const cssNames = new Set( + entrypoints + .filter((e) => e.type === 'content-script-style') + .map((e) => e.name), + ); + + const seen = new Set(); + for (const entry of entrypoints) { + const ref = getRef(entry); + let constName = refToConstName(ref); + let suffix = 2; + while (seen.has(constName)) { + constName = refToConstName(`${ref}_${suffix++}`); + } + seen.add(constName); + + const path = getEntrypointBundlePath(entry, outDir, getBundleExt(entry)); + lines.push(`export const ${constName} = ${JSON.stringify(path)};`); + + if (entry.type === 'content-script' && cssNames.has(entry.name)) { + const cssRef = `${ref}_css`; + let cssConstName = refToConstName(cssRef); + let cssSuffix = 2; + while (seen.has(cssConstName)) { + cssConstName = refToConstName(`${cssRef}_${cssSuffix++}`); + } + seen.add(cssConstName); + const cssPath = getEntrypointBundlePath(entry, outDir, '.css'); + lines.push(`export const ${cssConstName} = ${JSON.stringify(cssPath)};`); + } + } + + lines.push(''); + return lines.join('\n'); +} diff --git a/packages/entrypoint-refs/tsconfig.json b/packages/entrypoint-refs/tsconfig.json new file mode 100644 index 000000000..9b2dc061d --- /dev/null +++ b/packages/entrypoint-refs/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "exclude": ["node_modules/**", "dist/**"] +} diff --git a/packages/entrypoint-refs/vitest.config.ts b/packages/entrypoint-refs/vitest.config.ts new file mode 100644 index 000000000..d6d6dd072 --- /dev/null +++ b/packages/entrypoint-refs/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + mockReset: true, + restoreMocks: true, + }, +}); diff --git a/packages/wxt/src/core/utils/index.ts b/packages/wxt/src/core/utils/index.ts index 3110cb1d8..7ef361b18 100644 --- a/packages/wxt/src/core/utils/index.ts +++ b/packages/wxt/src/core/utils/index.ts @@ -1 +1,2 @@ export { normalizePath } from './paths'; +export { getEntrypointBundlePath } from './entrypoints'; diff --git a/packages/wxt/src/types.ts b/packages/wxt/src/types.ts index 09f3bf3c8..509ec320c 100644 --- a/packages/wxt/src/types.ts +++ b/packages/wxt/src/types.ts @@ -612,6 +612,15 @@ export interface BaseEntrypointOptions { * @default undefined */ exclude?: TargetBrowser[]; + /** + * Stable identifier for this entrypoint, independent of its filename. Modules + * like `@wxt-dev/entrypoint-refs` can use it to emit constant names that do + * not change when the source file is renamed. Defaults to the entrypoint + * name. + * + * @default undefined + */ + ref?: string; } export interface BackgroundEntrypointOptions extends BaseEntrypointOptions {