From 1e7c738de4c60c240d902ef97930df8fab5c1e9d Mon Sep 17 00:00:00 2001 From: Aaron Klinker Date: Thu, 22 Jun 2023 16:49:16 -0500 Subject: [PATCH] feat: Build entrypoints and output `manfiest.json` --- demo/common/style.css | 4 + demo/entrypoints/example.sandbox/index.html | 10 + .../index.ts} | 1 + demo/entrypoints/popup.html | 1 + demo/entrypoints/sandbox.html | 10 + demo/package.json | 3 + package.json | 10 +- pnpm-lock.yaml | 17 +- src/cli/commands/dev.ts | 2 +- src/cli/commands/prepare.ts | 1 - src/index.ts | 21 +- src/types/external.ts | 89 ++++-- src/types/internal.ts | 14 +- src/utils/__tests__/entrypoints.test.ts | 51 ++++ src/utils/__tests__/findEntrypoints.test.ts | 109 ++++--- src/utils/__tests__/groupEntrypoints.test.ts | 103 +++++++ src/utils/__tests__/importTsFile.test.ts | 3 - src/utils/buildEntrypoints.ts | 93 ++++++ src/utils/entrypoints.ts | 33 +++ src/utils/findEntrypoints.ts | 25 +- src/utils/getInternalConfig.ts | 18 +- src/utils/groupEntrypoints.ts | 48 +++ src/utils/importTsFile.ts | 12 +- src/utils/manifest.ts | 274 ++++++++++++++++++ src/utils/printBuildSummary.ts | 51 ++++ src/utils/printTable.ts | 28 ++ src/utils/removeEmptyDirs.ts | 19 ++ src/utils/trimExtension.ts | 10 + src/vite-plugins/download.ts | 5 +- src/vite-plugins/index.ts | 1 + src/vite-plugins/multipageMove.ts | 67 +++++ src/vite-plugins/unimport.ts | 7 +- tsconfig.base.json | 3 + 33 files changed, 1049 insertions(+), 94 deletions(-) create mode 100644 demo/common/style.css create mode 100644 demo/entrypoints/example.sandbox/index.html rename demo/entrypoints/{overlay.content.ts => overlay.content/index.ts} (89%) create mode 100644 demo/entrypoints/sandbox.html create mode 100644 src/utils/__tests__/entrypoints.test.ts create mode 100644 src/utils/__tests__/groupEntrypoints.test.ts delete mode 100644 src/utils/__tests__/importTsFile.test.ts create mode 100644 src/utils/buildEntrypoints.ts create mode 100644 src/utils/entrypoints.ts create mode 100644 src/utils/groupEntrypoints.ts create mode 100644 src/utils/manifest.ts create mode 100644 src/utils/printBuildSummary.ts create mode 100644 src/utils/printTable.ts create mode 100644 src/utils/removeEmptyDirs.ts create mode 100644 src/utils/trimExtension.ts create mode 100644 src/vite-plugins/multipageMove.ts diff --git a/demo/common/style.css b/demo/common/style.css new file mode 100644 index 00000000..ea1e941c --- /dev/null +++ b/demo/common/style.css @@ -0,0 +1,4 @@ +body { + margin: 0; + padding: 0; +} diff --git a/demo/entrypoints/example.sandbox/index.html b/demo/entrypoints/example.sandbox/index.html new file mode 100644 index 00000000..bf8cb32e --- /dev/null +++ b/demo/entrypoints/example.sandbox/index.html @@ -0,0 +1,10 @@ + + + + + + Document + + + + diff --git a/demo/entrypoints/overlay.content.ts b/demo/entrypoints/overlay.content/index.ts similarity index 89% rename from demo/entrypoints/overlay.content.ts rename to demo/entrypoints/overlay.content/index.ts index a5891d43..7cb843c3 100644 --- a/demo/entrypoints/overlay.content.ts +++ b/demo/entrypoints/overlay.content/index.ts @@ -1,5 +1,6 @@ import { defineContentScript, mountContentScriptUi } from 'exvite/client'; import browser from 'webextension-polyfill'; +import '../../common/style.css'; export default defineContentScript({ matches: ['*://*/*'], diff --git a/demo/entrypoints/popup.html b/demo/entrypoints/popup.html index 53cb0387..9842d2de 100644 --- a/demo/entrypoints/popup.html +++ b/demo/entrypoints/popup.html @@ -4,6 +4,7 @@ Popup +

Hello popup!

diff --git a/demo/entrypoints/sandbox.html b/demo/entrypoints/sandbox.html new file mode 100644 index 00000000..3a04cf03 --- /dev/null +++ b/demo/entrypoints/sandbox.html @@ -0,0 +1,10 @@ + + + + + + Document + + + + diff --git a/demo/package.json b/demo/package.json index 163d83f9..aab52a30 100644 --- a/demo/package.json +++ b/demo/package.json @@ -1,4 +1,7 @@ { + "name": "Demo Extension", + "version": "1.0.0", + "description": "Demo extension for exvite", "type": "module", "scripts": { "dev": "pnpm -w build && exvite", diff --git a/package.json b/package.json index 5fcd8e68..5401bd93 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,8 @@ }, "scripts": { "dev": "tsx src/cli.ts", - "build": "run-s build:*", - "build:js": "tsup src/index.ts --clean --sourcemap --dts --format esm,cjs", + "build": "run-s -s build:*", + "build:js": "tsup src/index.ts --sourcemap --dts --format esm,cjs", "build:cli": "tsup src/cli/index.ts -d dist/cli --sourcemap", "build:client": "tsup src/client/index.ts -d dist/client --sourcemap --dts --format esm,cjs", "format": "prettier --write .", @@ -49,18 +49,22 @@ "cac": "^6.7.14", "consola": "^3.1.0", "fast-glob": "^3.2.12", + "filesize": "^10.0.7", "fs-extra": "^11.1.1", "jiti": "^1.18.2", "json5": "^2.2.3", "linkedom": "^0.14.26", + "picocolors": "^1.0.0", "picomatch": "^2.3.1", "unimport": "^3.0.8", - "vite": "^4.3.9" + "vite": "^4.3.9", + "webextension-polyfill": "^0.10.0" }, "devDependencies": { "@types/fs-extra": "^11.0.1", "@types/node": "^20.3.1", "@types/picomatch": "^2.3.0", + "@types/webextension-polyfill": "^0.10.0", "npm-run-all": "^4.1.5", "prettier": "^2.8.8", "pretty-quick": "^3.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34018d0f..b11f86bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,14 +6,17 @@ importers: '@types/fs-extra': ^11.0.1 '@types/node': ^20.3.1 '@types/picomatch': ^2.3.0 + '@types/webextension-polyfill': ^0.10.0 cac: ^6.7.14 consola: ^3.1.0 fast-glob: ^3.2.12 + filesize: ^10.0.7 fs-extra: ^11.1.1 jiti: ^1.18.2 json5: ^2.2.3 linkedom: ^0.14.26 npm-run-all: ^4.1.5 + picocolors: ^1.0.0 picomatch: ^2.3.1 prettier: ^2.8.8 pretty-quick: ^3.1.3 @@ -29,17 +32,21 @@ importers: cac: 6.7.14 consola: 3.1.0 fast-glob: 3.2.12 + filesize: 10.0.7 fs-extra: 11.1.1 jiti: 1.18.2 json5: 2.2.3 linkedom: 0.14.26 + picocolors: 1.0.0 picomatch: 2.3.1 unimport: 3.0.8 vite: 4.3.9_@types+node@20.3.1 + webextension-polyfill: 0.10.0 devDependencies: '@types/fs-extra': 11.0.1 '@types/node': 20.3.1 '@types/picomatch': 2.3.0 + '@types/webextension-polyfill': 0.10.0 npm-run-all: 4.1.5 prettier: 2.8.8 pretty-quick: 3.1.3_prettier@2.8.8 @@ -48,7 +55,6 @@ importers: tsx: 3.12.7 typescript: 5.1.3 vitest: 0.32.2 - webextension-polyfill: 0.10.0 demo: specifiers: @@ -1563,6 +1569,14 @@ packages: dependencies: reusify: 1.0.4 + /filesize/10.0.7: + resolution: + { + integrity: sha512-iMRG7Qo9nayLoU3PNCiLizYtsy4W1ClrapeCwEgtiQelOAOuRJiw4QaLI+sSr8xr901dgHv+EYP2bCusGZgoiA==, + } + engines: { node: '>= 10.4.0' } + dev: false + /fill-range/7.0.1: resolution: { @@ -3555,6 +3569,7 @@ packages: { integrity: sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g==, } + dev: false /webidl-conversions/4.0.2: resolution: diff --git a/src/cli/commands/dev.ts b/src/cli/commands/dev.ts index 24a9fb04..f2246f62 100644 --- a/src/cli/commands/dev.ts +++ b/src/cli/commands/dev.ts @@ -1,7 +1,7 @@ import * as exvite from '../..'; export async function dev(root: any, { mode, config }: any) { - const server = await exvite.createServer({ + await exvite.createServer({ mode, root, configFile: config, diff --git a/src/cli/commands/prepare.ts b/src/cli/commands/prepare.ts index 520b227b..2b3ca89d 100644 --- a/src/cli/commands/prepare.ts +++ b/src/cli/commands/prepare.ts @@ -1,5 +1,4 @@ import { consola } from 'consola'; -import { createServer } from '../..'; export async function prepare(root: any, { mode, config }: any) { consola.warn('exvite prepare: Not implemented'); diff --git a/src/index.ts b/src/index.ts index fde488d5..e2ce82e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,10 @@ import { BuildOutput, ExviteDevServer, InlineConfig } from './types'; -import * as vite from 'vite'; import { getInternalConfig } from './utils/getInternalConfig'; import { findEntrypoints } from './utils/findEntrypoints'; +import { buildEntrypoints } from './utils/buildEntrypoints'; +import { generateMainfest, writeManifest } from './utils/manifest'; +import { printBuildSummary } from './utils/printBuildSummary'; +import fs from 'fs-extra'; export * from './types/external'; export * from './utils/defineConfig'; @@ -11,9 +14,18 @@ export * from './utils/defineConfig'; */ export async function build(config: InlineConfig): Promise { const internalConfig = await getInternalConfig(config, 'build'); + await fs.rm(internalConfig.outDir, { recursive: true, force: true }); + await fs.ensureDir(internalConfig.outDir); + const entrypoints = await findEntrypoints(internalConfig); - console.log(entrypoints); - throw Error('Not implemented'); + const output = await buildEntrypoints(entrypoints, internalConfig); + + const manifest = await generateMainfest(entrypoints, output, internalConfig); + await writeManifest(manifest, internalConfig); + + printBuildSummary(output, internalConfig); + + return output; } export async function createServer( @@ -21,6 +33,7 @@ export async function createServer( ): Promise { const internalConfig = await getInternalConfig(config, 'serve'); const entrypoints = await findEntrypoints(internalConfig); - const server = await vite.createServer(internalConfig.vite); + await buildEntrypoints(entrypoints, internalConfig); + throw Error('Not implemented'); } diff --git a/src/types/external.ts b/src/types/external.ts index da4b10e0..0d0af96c 100644 --- a/src/types/external.ts +++ b/src/types/external.ts @@ -1,5 +1,6 @@ import * as vite from 'vite'; import { UnimportPluginOptions } from 'unimport/unplugin'; +import { Manifest } from 'webextension-polyfill'; export interface InlineConfig { root?: string; @@ -17,6 +18,7 @@ export interface InlineConfig { manifestVersion?: TargetManifestVersion; logger?: Logger; vite?: Omit; + manifest?: UserManifest; } export interface ExviteInlineViteConfig @@ -24,7 +26,7 @@ export interface ExviteInlineViteConfig build?: Omit; } -export interface BuildOutput {} +export type BuildOutput = vite.Rollup.RollupOutput[]; export interface ExviteDevServer {} @@ -42,7 +44,36 @@ export interface Logger { fatal(...args: any[]): void; } -export interface GenericEntrypoint { +export interface BaseEntrypoint { + /** + * The entrypoint's name. This is the filename or dirname without the type suffix. + * + * Examples: + * - `popup.html` → `popup` + * - `options/index.html` → `options` + * - `named.sandbox.html` → `named` + * - `named.sandbox/index.html` → `named` + * - `sandbox.html` → `sandbox` + * - `sandbox.index.html` → `sandbox` + * - `overlay.content.ts` → `overlay` + * - `overlay.content/index.ts` → `overlay` + * + * The name is used when generating an output file: + * `/.` + */ + name: string; + /** + * Absolute path to the entrypoint's input file. + */ + inputPath: string; + /** + * Absolute path to the entrypoint's output directory. Can be the`InternalConfg.outDir` or a + * subdirectory of it. + */ + outputDir: string; +} + +export interface GenericEntrypoint extends BaseEntrypoint { type: | 'sandbox' | 'bookmarks' @@ -53,41 +84,38 @@ export interface GenericEntrypoint { | 'unlisted-page' | 'unlisted-page' | 'unlisted-script'; - inputPath: string; - outputDir: string; } -export interface BackgroundEntrypoint { +export interface BackgroundEntrypoint extends BaseEntrypoint { type: 'background'; - options: {}; - inputPath: string; - outputDir: string; + options: { + persistent?: boolean; + type?: 'module'; + }; } -export interface ContentScriptEntrypoint { +export interface ContentScriptEntrypoint extends BaseEntrypoint { type: 'content-script'; options: Omit; - inputPath: string; - outputDir: string; } -export interface PopupEntrypoint { +export interface PopupEntrypoint extends BaseEntrypoint { type: 'popup'; options: { + /** + * Defaults to "browser_action" to be equivalent to MV3's "action" key + */ + mv2Key?: 'browser_action' | 'page_action'; defaultIcon?: Record; defaultTitle?: string; }; - inputPath: string; - outputDir: string; } -export interface OptionsEntrypoint { +export interface OptionsEntrypoint extends BaseEntrypoint { type: 'options'; options: { openInTab?: boolean; }; - inputPath: string; - outputDir: string; } export type Entrypoint = @@ -112,3 +140,30 @@ export interface BackgroundScriptDefintition { type?: 'module'; main(): void; } + +/** + * Manifest customization available in the `exvite.config.ts` file. Any missing fields like "name" + * and "version" are managed automatically, and don't need to be listed here. + */ +export type UserManifest = Omit< + Manifest.WebExtensionManifest, + | 'action' + | 'background' + | 'browser_action' + | 'chrome_url_overrides' + | 'content_scripts' + | 'description' + | 'devtools_page' + | 'manifest_version' + | 'name' + | 'options_page' + | 'options_ui' + | 'sandbox' + | 'page_action' + | 'popup' + | 'short_name' + | 'sidepanel' + | 'sidebar_action' + | 'version' + | 'version_name' +>; diff --git a/src/types/internal.ts b/src/types/internal.ts index 942232a4..cec0a24e 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -1,7 +1,14 @@ import * as vite from 'vite'; -import { Logger, TargetBrowser, TargetManifestVersion } from './external'; +import { + Entrypoint, + Logger, + TargetBrowser, + TargetManifestVersion, + UserManifest, +} from './external'; export interface InternalConfig { + root: string; srcDir: string; entrypointsDir: string; outDir: string; @@ -15,5 +22,8 @@ export interface InternalConfig { browser: TargetBrowser; manifestVersion: TargetManifestVersion; logger: Logger; - vite?: vite.InlineConfig; + vite: vite.InlineConfig; + manifest: UserManifest; } + +export type EntrypointGroup = Entrypoint | Entrypoint[]; diff --git a/src/utils/__tests__/entrypoints.test.ts b/src/utils/__tests__/entrypoints.test.ts new file mode 100644 index 00000000..01f15cec --- /dev/null +++ b/src/utils/__tests__/entrypoints.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { getEntrypointName, getEntrypointOutputFile } from '../entrypoints'; +import { Entrypoint } from '../../types'; +import { resolve } from 'path'; + +describe('Entrypoint Utils', () => { + describe('getEntrypointName', () => { + const entrypointsDir = '/entrypoints'; + + it.each<[string, string]>([ + [resolve(entrypointsDir, 'popup.html'), 'popup'], + [resolve(entrypointsDir, 'options/index.html'), 'options'], + [resolve(entrypointsDir, 'example.sandbox/index.html'), 'example'], + [resolve(entrypointsDir, 'some.content/index.ts'), 'some'], + [resolve(entrypointsDir, 'overlay.content.ts'), 'overlay'], + ])('should convert %s to %s', (inputPath, expected) => { + const actual = getEntrypointName(entrypointsDir, inputPath); + expect(actual).toBe(expected); + }); + }); + + describe('getEntrypointOutputFile', () => { + const outDir = '/.output'; + it.each<{ expected: string; name: string; ext: string; outputDir: string }>( + [ + { + name: 'popup', + ext: '.html', + outputDir: outDir, + expected: resolve(outDir, 'popup.html'), + }, + { + name: 'overlay', + ext: '.ts', + outputDir: resolve(outDir, 'content-scripts'), + expected: resolve(outDir, 'content-scripts', 'overlay.ts'), + }, + ], + )('should return %s', ({ name, ext, expected, outputDir }) => { + const entrypoint: Entrypoint = { + type: 'unlisted-page', + inputPath: '...', + name, + outputDir, + }; + + const actual = getEntrypointOutputFile(entrypoint, ext); + expect(actual).toBe(expected); + }); + }); +}); diff --git a/src/utils/__tests__/findEntrypoints.test.ts b/src/utils/__tests__/findEntrypoints.test.ts index fefece8d..68996811 100644 --- a/src/utils/__tests__/findEntrypoints.test.ts +++ b/src/utils/__tests__/findEntrypoints.test.ts @@ -59,8 +59,9 @@ describe('findEntrypoints', () => { `, { type: 'popup', + name: 'popup', inputPath: resolve(config.entrypointsDir, 'popup.html'), - outputDir: resolve(config.outDir, 'popup'), + outputDir: config.outDir, options: { defaultIcon: { '16': '/icon/16.png' }, defaultTitle: 'Default Title', @@ -78,8 +79,9 @@ describe('findEntrypoints', () => { `, { type: 'popup', + name: 'popup', inputPath: resolve(config.entrypointsDir, 'popup/index.html'), - outputDir: resolve(config.outDir, 'popup'), + outputDir: config.outDir, options: { defaultTitle: 'Title', }, @@ -110,8 +112,9 @@ describe('findEntrypoints', () => { `, { type: 'options', + name: 'options', inputPath: resolve(config.entrypointsDir, 'options.html'), - outputDir: resolve(config.outDir, 'options'), + outputDir: config.outDir, options: {}, }, ], @@ -127,8 +130,9 @@ describe('findEntrypoints', () => { `, { type: 'options', + name: 'options', inputPath: resolve(config.entrypointsDir, 'options/index.html'), - outputDir: resolve(config.outDir, 'options'), + outputDir: config.outDir, options: { openInTab: true, }, @@ -152,16 +156,27 @@ describe('findEntrypoints', () => { 'overlay.content.ts', { type: 'content-script', + name: 'overlay', inputPath: resolve(config.entrypointsDir, 'overlay.content.ts'), - outputDir: resolve(config.outDir, 'content-scripts/overlay'), + outputDir: resolve(config.outDir, 'content-scripts'), }, ], [ 'overlay.content/index.ts', { type: 'content-script', + name: 'overlay', inputPath: resolve(config.entrypointsDir, 'overlay.content/index.ts'), - outputDir: resolve(config.outDir, 'content-scripts/overlay'), + outputDir: resolve(config.outDir, 'content-scripts'), + }, + ], + [ + 'overlay.content.tsx', + { + type: 'content-script', + name: 'overlay', + inputPath: resolve(config.entrypointsDir, 'overlay.content.tsx'), + outputDir: resolve(config.outDir, 'content-scripts'), }, ], ])( @@ -171,19 +186,13 @@ describe('findEntrypoints', () => { matches: [''], }; globMock.mockResolvedValueOnce([path]); - importTsFileMock.mockResolvedValue({ - defaultExport: options, - dependencies: [], - }); + importTsFileMock.mockResolvedValue(options); const entrypoints = await findEntrypoints(config); expect(entrypoints).toHaveLength(1); expect(entrypoints[0]).toEqual({ ...expected, options }); - expect(importTsFileMock).toBeCalledWith( - { mode: config.mode, command: config.command }, - expected.inputPath, - ); + expect(importTsFileMock).toBeCalledWith(expected.inputPath); }, ); @@ -192,8 +201,9 @@ describe('findEntrypoints', () => { 'background.ts', { type: 'background', + name: 'background', inputPath: resolve(config.entrypointsDir, 'background.ts'), - outputDir: resolve(config.outDir, 'background'), + outputDir: config.outDir, }, ], ])( @@ -203,19 +213,13 @@ describe('findEntrypoints', () => { matches: [''], }; globMock.mockResolvedValueOnce([path]); - importTsFileMock.mockResolvedValue({ - defaultExport: options, - dependencies: [], - }); + importTsFileMock.mockResolvedValue(options); const entrypoints = await findEntrypoints(config); expect(entrypoints).toHaveLength(1); expect(entrypoints[0]).toEqual({ ...expected, options }); - expect(importTsFileMock).toBeCalledWith( - { mode: config.mode, command: config.command }, - expected.inputPath, - ); + expect(importTsFileMock).toBeCalledWith(expected.inputPath); }, ); @@ -225,32 +229,36 @@ describe('findEntrypoints', () => { 'sandbox.html', { type: 'sandbox', + name: 'sandbox', inputPath: resolve(config.entrypointsDir, 'sandbox.html'), - outputDir: resolve(config.outDir, 'sandbox'), + outputDir: config.outDir, }, ], [ 'sandbox/index.html', { type: 'sandbox', + name: 'sandbox', inputPath: resolve(config.entrypointsDir, 'sandbox/index.html'), - outputDir: resolve(config.outDir, 'sandbox'), + outputDir: config.outDir, }, ], [ 'named.sandbox.html', { type: 'sandbox', + name: 'named', inputPath: resolve(config.entrypointsDir, 'named.sandbox.html'), - outputDir: resolve(config.outDir, 'named.sandbox'), + outputDir: config.outDir, }, ], [ 'named.sandbox/index.html', { type: 'sandbox', + name: 'named', inputPath: resolve(config.entrypointsDir, 'named.sandbox/index.html'), - outputDir: resolve(config.outDir, 'named.sandbox'), + outputDir: config.outDir, }, ], @@ -259,16 +267,18 @@ describe('findEntrypoints', () => { 'bookmarks.html', { type: 'bookmarks', + name: 'bookmarks', inputPath: resolve(config.entrypointsDir, 'bookmarks.html'), - outputDir: resolve(config.outDir, 'bookmarks'), + outputDir: config.outDir, }, ], [ 'bookmarks/index.html', { type: 'bookmarks', + name: 'bookmarks', inputPath: resolve(config.entrypointsDir, 'bookmarks/index.html'), - outputDir: resolve(config.outDir, 'bookmarks'), + outputDir: config.outDir, }, ], @@ -277,16 +287,18 @@ describe('findEntrypoints', () => { 'history.html', { type: 'history', + name: 'history', inputPath: resolve(config.entrypointsDir, 'history.html'), - outputDir: resolve(config.outDir, 'history'), + outputDir: config.outDir, }, ], [ 'history/index.html', { type: 'history', + name: 'history', inputPath: resolve(config.entrypointsDir, 'history/index.html'), - outputDir: resolve(config.outDir, 'history'), + outputDir: config.outDir, }, ], @@ -295,16 +307,18 @@ describe('findEntrypoints', () => { 'newtab.html', { type: 'newtab', + name: 'newtab', inputPath: resolve(config.entrypointsDir, 'newtab.html'), - outputDir: resolve(config.outDir, 'newtab'), + outputDir: config.outDir, }, ], [ 'newtab/index.html', { type: 'newtab', + name: 'newtab', inputPath: resolve(config.entrypointsDir, 'newtab/index.html'), - outputDir: resolve(config.outDir, 'newtab'), + outputDir: config.outDir, }, ], @@ -313,32 +327,36 @@ describe('findEntrypoints', () => { 'sidepanel.html', { type: 'sidepanel', + name: 'sidepanel', inputPath: resolve(config.entrypointsDir, 'sidepanel.html'), - outputDir: resolve(config.outDir, 'sidepanel'), + outputDir: config.outDir, }, ], [ 'sidepanel/index.html', { type: 'sidepanel', + name: 'sidepanel', inputPath: resolve(config.entrypointsDir, 'sidepanel/index.html'), - outputDir: resolve(config.outDir, 'sidepanel'), + outputDir: config.outDir, }, ], [ 'named.sidepanel.html', { type: 'sidepanel', + name: 'named', inputPath: resolve(config.entrypointsDir, 'named.sidepanel.html'), - outputDir: resolve(config.outDir, 'named.sidepanel'), + outputDir: config.outDir, }, ], [ 'named.sidepanel/index.html', { type: 'sidepanel', + name: 'named', inputPath: resolve(config.entrypointsDir, 'named.sidepanel/index.html'), - outputDir: resolve(config.outDir, 'named.sidepanel'), + outputDir: config.outDir, }, ], @@ -347,16 +365,18 @@ describe('findEntrypoints', () => { 'devtools.html', { type: 'devtools', + name: 'devtools', inputPath: resolve(config.entrypointsDir, 'devtools.html'), - outputDir: resolve(config.outDir, 'devtools'), + outputDir: config.outDir, }, ], [ 'devtools/index.html', { type: 'devtools', + name: 'devtools', inputPath: resolve(config.entrypointsDir, 'devtools/index.html'), - outputDir: resolve(config.outDir, 'devtools'), + outputDir: config.outDir, }, ], @@ -365,16 +385,18 @@ describe('findEntrypoints', () => { 'onboarding.html', { type: 'unlisted-page', + name: 'onboarding', inputPath: resolve(config.entrypointsDir, 'onboarding.html'), - outputDir: resolve(config.outDir, 'onboarding'), + outputDir: config.outDir, }, ], [ 'onboarding/index.html', { type: 'unlisted-page', + name: 'onboarding', inputPath: resolve(config.entrypointsDir, 'onboarding/index.html'), - outputDir: resolve(config.outDir, 'onboarding'), + outputDir: config.outDir, }, ], @@ -383,8 +405,9 @@ describe('findEntrypoints', () => { 'injected.ts', { type: 'unlisted-script', + name: 'injected', inputPath: resolve(config.entrypointsDir, 'injected.ts'), - outputDir: resolve(config.outDir, 'injected'), + outputDir: config.outDir, }, ], ])('should find entrypoint for %s', async (path, expected) => { @@ -410,4 +433,6 @@ describe('findEntrypoints', () => { 'should ignore entrypoints starting with a . (preventing warnings like .DS_Store files)', () => {}, ); + + it.todo('should not allow multiple entrypoints with the same name', () => {}); }); diff --git a/src/utils/__tests__/groupEntrypoints.test.ts b/src/utils/__tests__/groupEntrypoints.test.ts new file mode 100644 index 00000000..240bba25 --- /dev/null +++ b/src/utils/__tests__/groupEntrypoints.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { Entrypoint } from '../../types'; +import { groupEntrypoints } from '../groupEntrypoints'; + +const background: Entrypoint = { + type: 'background', + name: 'background', + inputPath: '/background.ts', + outputDir: '/.output/background', + options: {}, +}; +const contentScript: Entrypoint = { + type: 'content-script', + name: 'overlay', + inputPath: '/overlay.content.ts', + outputDir: '/.output/content-scripts/overlay', + options: { + matches: [''], + }, +}; +const unlistedScript: Entrypoint = { + type: 'unlisted-script', + name: 'injected', + inputPath: '/injected.ts', + outputDir: '/.output/injected', +}; +const popup: Entrypoint = { + type: 'popup', + name: 'popup', + inputPath: '/popup.html', + outputDir: '/.output/popup', + options: {}, +}; +const unlistedPage: Entrypoint = { + type: 'unlisted-page', + name: 'onboarding', + inputPath: '/onboarding.html', + outputDir: '/.output/onboarding', +}; +const options: Entrypoint = { + type: 'options', + name: 'options', + inputPath: '/options.html', + outputDir: '/.output/options', + options: {}, +}; +const sandbox1: Entrypoint = { + type: 'sandbox', + name: 'sandbox', + inputPath: '/sandbox1.html', + outputDir: '/.output/sandbox1', +}; +const sandbox2: Entrypoint = { + type: 'sandbox', + name: 'sandbox2', + inputPath: '/sandbox2.html', + outputDir: '/.output/sandbox2', +}; + +describe('groupEntrypoints', () => { + it('should keep scripts separate', () => { + const entrypoints: Entrypoint[] = [ + contentScript, + background, + unlistedScript, + popup, + ]; + const expected = [contentScript, background, unlistedScript, [popup]]; + + const actual = groupEntrypoints(entrypoints); + + expect(actual).toEqual(expected); + }); + + it('should group extension pages together', () => { + const entrypoints: Entrypoint[] = [ + popup, + background, + unlistedPage, + options, + sandbox1, + ]; + const expected = [[popup, unlistedPage, options], background, [sandbox1]]; + + const actual = groupEntrypoints(entrypoints); + + expect(actual).toEqual(expected); + }); + + it('should group sandbox pages together', () => { + const entrypoints: Entrypoint[] = [ + sandbox1, + popup, + sandbox2, + contentScript, + ]; + const expected = [[sandbox1, sandbox2], [popup], contentScript]; + + const actual = groupEntrypoints(entrypoints); + + expect(actual).toEqual(expected); + }); +}); diff --git a/src/utils/__tests__/importTsFile.test.ts b/src/utils/__tests__/importTsFile.test.ts deleted file mode 100644 index 4b271a04..00000000 --- a/src/utils/__tests__/importTsFile.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describe } from 'vitest'; - -describe.todo('importTsFile'); diff --git a/src/utils/buildEntrypoints.ts b/src/utils/buildEntrypoints.ts new file mode 100644 index 00000000..88da2d85 --- /dev/null +++ b/src/utils/buildEntrypoints.ts @@ -0,0 +1,93 @@ +import * as vite from 'vite'; +import { BuildOutput, Entrypoint, InternalConfig } from '../types'; +import { groupEntrypoints } from './groupEntrypoints'; +import * as plugins from '../vite-plugins'; +import { removeEmptyDirs } from './removeEmptyDirs'; +import { getEntrypointBundlePath } from './entrypoints'; + +export async function buildEntrypoints( + entrypoints: Entrypoint[], + config: InternalConfig, +): Promise { + const groups = groupEntrypoints(entrypoints); + + const outputs: BuildOutput[] = []; + for (const group of groups) { + const output = Array.isArray(group) + ? await buildMultipleEntrypoints(group, config) + : await buildSingleEntrypoint(group, config); + outputs.push(output); + } + + // Remove any empty directories from moving outputs around + await removeEmptyDirs(config.outDir); + + return outputs.flat(); +} + +/** + * Use Vite's lib mode + IIFE format to bundle the entrypoint to a single file. + */ +async function buildSingleEntrypoint( + entrypoint: Entrypoint, + config: InternalConfig, +): Promise { + const libMode: vite.InlineConfig = { + build: { + lib: { + entry: entrypoint.inputPath, + formats: ['iife'], + name: entrypoint.name, + fileName: entrypoint.name, + }, + rollupOptions: { + output: { + entryFileNames: getEntrypointBundlePath( + entrypoint, + config.outDir, + '.js', + ), + // Output content script CSS to assets/ with a hash to prevent conflicts. Defaults to + // "[name].[ext]" in lib mode, which usually results in "style.css". That means multiple + // content scripts with styles would overwrite each other if it weren't changed below. + assetFileNames: `assets/${entrypoint.name}-[hash].[ext]`, + }, + }, + }, + }; + const entryConfig = vite.mergeConfig( + libMode, + config.vite, + ) as vite.InlineConfig; + + const res = await vite.build(entryConfig); + return res as BuildOutput; +} + +/** + * Use Vite's multipage build to bundle all the entrypoints in a single step. + */ +async function buildMultipleEntrypoints( + entrypoints: Entrypoint[], + config: InternalConfig, +): Promise { + const multiPage: vite.InlineConfig = { + plugins: [plugins.multipageMove(entrypoints, config)], + build: { + rollupOptions: { + input: entrypoints.reduce>((input, entry) => { + input[entry.name] = entry.inputPath; + return input; + }, {}), + }, + }, + }; + + const entryConfig = vite.mergeConfig( + multiPage, + config.vite, + ) as vite.InlineConfig; + + const res = await vite.build(entryConfig); + return res as BuildOutput; +} diff --git a/src/utils/entrypoints.ts b/src/utils/entrypoints.ts new file mode 100644 index 00000000..9d9b303d --- /dev/null +++ b/src/utils/entrypoints.ts @@ -0,0 +1,33 @@ +import { Entrypoint } from '../types'; +import path, { relative, resolve } from 'node:path'; + +export function getEntrypointName( + entrypointsDir: string, + inputPath: string, + // type: Entrypoint['type'], +): string { + const relativePath = path.relative(entrypointsDir, inputPath); + // Grab the string up to the first . or / + const name = relativePath.split(/[\.\/]/, 2)[0]; + + return name; +} + +export function getEntrypointOutputFile( + entrypoint: Entrypoint, + ext: string, +): string { + return resolve(entrypoint.outputDir, `${entrypoint.name}${ext}`); +} + +/** + * Return's the entrypoint's output path relative to the output directory. Used for paths in the + * manifest and rollup's bundle. + */ +export function getEntrypointBundlePath( + entrypoint: Entrypoint, + outDir: string, + ext: string, +): string { + return relative(outDir, getEntrypointOutputFile(entrypoint, ext)); +} diff --git a/src/utils/findEntrypoints.ts b/src/utils/findEntrypoints.ts index 97984c6c..7d1aa401 100644 --- a/src/utils/findEntrypoints.ts +++ b/src/utils/findEntrypoints.ts @@ -15,6 +15,7 @@ import { parseHTML } from 'linkedom'; import JSON5 from 'json5'; import { importTsFile } from './importTsFile'; import glob from 'fast-glob'; +import { getEntrypointName } from './entrypoints'; /** * Return entrypoints and their configuration by looking through the @@ -70,15 +71,11 @@ export async function findEntrypoints( ); break; default: - const name = relativePath.split(/[\.\/]/, 2)[0]; - const outDir = - name === type || type.startsWith('unlisted') - ? name - : `${name}.${type}`; entrypoint = { type, + name: getEntrypointName(config.entrypointsDir, path), inputPath: path, - outputDir: resolve(config.outDir, outDir), + outputDir: config.outDir, }; } @@ -118,9 +115,10 @@ async function getPopupEntrypoint( return { type: 'popup', + name: 'popup', options, inputPath: path, - outputDir: resolve(config.outDir, 'popup'), + outputDir: config.outDir, }; } @@ -145,9 +143,10 @@ async function getOptionsEntrypoint( return { type: 'options', + name: 'options', options, inputPath: path, - outputDir: resolve(config.outDir, 'options'), + outputDir: config.outDir, }; } @@ -165,8 +164,9 @@ async function getBackgroundEntrypoint( } return { type: 'background', + name: 'background', inputPath: path, - outputDir: resolve(config.outDir, 'background'), + outputDir: config.outDir, options: options, }; } @@ -187,8 +187,9 @@ async function getContentScriptEntrypoint( } return { type: 'content-script', + name: getEntrypointName(config.entrypointsDir, path), inputPath: path, - outputDir: resolve(config.outDir, 'content-scripts', name), + outputDir: resolve(config.outDir, 'content-scripts'), options, }; } @@ -218,8 +219,8 @@ const PATH_GLOB_TO_TYPE_MAP: Record = { 'background.ts': 'background', - '*.content.ts': 'content-script', - '*.content/index.ts': 'content-script', + '*.content.ts?(x)': 'content-script', + '*.content/index.ts?(x)': 'content-script', 'popup.html': 'popup', 'popup/index.html': 'popup', diff --git a/src/utils/getInternalConfig.ts b/src/utils/getInternalConfig.ts index 95a2861f..1502e802 100644 --- a/src/utils/getInternalConfig.ts +++ b/src/utils/getInternalConfig.ts @@ -26,8 +26,14 @@ export async function getInternalConfig( const browser = config.browser ?? 'chromium'; const manifestVersion = config.manifestVersion ?? (browser === 'chromium' ? 3 : 2); + const outDir = path.resolve( + root, + '.output', + `${browser}-mv${manifestVersion}`, + ); const baseConfig: InternalConfig = { + root, srcDir, entrypointsDir, storeIds: config.storeIds ?? {}, @@ -35,9 +41,10 @@ export async function getInternalConfig( manifestVersion, mode, command, - outDir: path.resolve(root, '.output', `${browser}-mv${manifestVersion}`), + outDir, logger: config.logger ?? consola, - vite: config.vite, + vite: config.vite ?? {}, + manifest: config.manifest ?? {}, }; // Load user config from file @@ -54,13 +61,16 @@ export async function getInternalConfig( const merged = vite.mergeConfig(baseConfig, userConfig) as InternalConfig; // Customize the default vite config - merged.vite ??= {}; merged.vite.root = root; merged.vite.configFile = false; + merged.vite.build ??= {}; + merged.vite.build.outDir = outDir; + merged.vite.build.emptyOutDir = false; + merged.vite.logLevel = 'silent'; merged.vite.plugins ??= []; merged.vite.plugins.push(plugins.unimport(root, srcDir, userConfig.imports)); - merged.vite.plugins.push(plugins.download()); + merged.vite.plugins.push(plugins.download(merged)); return merged; } diff --git a/src/utils/groupEntrypoints.ts b/src/utils/groupEntrypoints.ts new file mode 100644 index 00000000..7932168a --- /dev/null +++ b/src/utils/groupEntrypoints.ts @@ -0,0 +1,48 @@ +import { Entrypoint } from '..'; +import { EntrypointGroup } from '../types'; + +/** + * Entrypoints can be build in groups. HTML pages can all be built together in a single step, while + * content scripts must be build individually. + * + * This function returns the entrypoints put into these types of groups. + */ +export function groupEntrypoints(entrypoints: Entrypoint[]): EntrypointGroup[] { + const groupIndexMap: Partial> = {}; + const groups: EntrypointGroup[] = []; + + for (const entry of entrypoints) { + const group = ENTRY_TYPE_TO_GROUP_MAP[entry.type]; + if (group === 'no-group') { + groups.push(entry); + } else { + let groupIndex = groupIndexMap[group]; + if (groupIndex == null) { + groupIndex = groups.push([]) - 1; + groupIndexMap[group] = groupIndex; + } + (groups[groupIndex] as Entrypoint[]).push(entry); + } + } + + return groups; +} + +const ENTRY_TYPE_TO_GROUP_MAP: Record = { + sandbox: 'sandbox-page', + + popup: 'extension-page', + newtab: 'extension-page', + history: 'extension-page', + options: 'extension-page', + devtools: 'extension-page', + bookmarks: 'extension-page', + sidepanel: 'extension-page', + 'unlisted-page': 'extension-page', + + background: 'no-group', + 'content-script': 'no-group', + 'unlisted-script': 'no-group', +}; + +type Group = 'extension-page' | 'sandbox-page' | 'no-group'; diff --git a/src/utils/importTsFile.ts b/src/utils/importTsFile.ts index a3fc6b30..c9620084 100644 --- a/src/utils/importTsFile.ts +++ b/src/utils/importTsFile.ts @@ -1,18 +1,28 @@ +import { consola } from 'consola'; import createJITI from 'jiti'; +import transform from 'jiti/dist/babel'; export async function importTsFile(path: string): Promise { const jiti = createJITI(__filename, { alias: { 'webextension-polyfill': 'exvite', + '*.css': 'exvite', }, cache: false, esmResolve: true, interopDefault: true, + + transform(opts) { + // Remove CSS imports from the source code - Jiti can't handle them. + opts.source = opts.source.replace(/^import ['"].*\.css['"];?$/gm, ''); + // Call the default babel transformer with our modified source code + return transform(opts); + }, }); try { return await jiti(path); } catch (err) { - console.error(`Failed to import file: ${path}`, err); + consola.error(`Failed to import file: ${path}`); throw err; } } diff --git a/src/utils/manifest.ts b/src/utils/manifest.ts new file mode 100644 index 00000000..065f6c02 --- /dev/null +++ b/src/utils/manifest.ts @@ -0,0 +1,274 @@ +import { Entrypoint } from '..'; +import { Manifest } from 'webextension-polyfill'; +import { + BackgroundEntrypoint, + BuildOutput, + ContentScriptEntrypoint, + InternalConfig, + OptionsEntrypoint, + PopupEntrypoint, +} from '../types'; +import fs from 'fs-extra'; +import { resolve } from 'path'; +import { getEntrypointBundlePath } from './entrypoints'; + +/** + * Writes the manifest to the output directory. + */ +export async function writeManifest( + manifest: Manifest.WebExtensionManifest, + config: InternalConfig, +): Promise { + const str = + config.mode === 'production' + ? JSON.stringify(manifest) + : JSON.stringify(manifest, null, 2); + + await fs.writeFile(resolve(config.outDir, 'manifest.json'), str, 'utf-8'); +} + +/** + * Generates the manifest based on the config and entrypoints. + */ +export async function generateMainfest( + entrypoints: Entrypoint[], + buildOutput: BuildOutput, + config: InternalConfig, +): Promise { + const pkg = await getPackageJson(); + if (pkg.version == null) + throw Error('package.json does not include a version'); + if (pkg.name == null) throw Error('package.json does not include a name'); + if (pkg.description == null) + throw Error('package.json does not include a description'); + + const manifest: Manifest.WebExtensionManifest = { + manifest_version: config.manifestVersion, + name: pkg.name, + short_name: pkg.shortName, + version: simplifyVersion(pkg.version), + version_name: pkg.version, + ...config.manifest, + }; + + const entriesByType = entrypoints.reduce< + Partial> + >((map, entrypoint) => { + map[entrypoint.type] ??= []; + map[entrypoint.type]?.push(entrypoint); + return map; + }, {}); + + const background = entriesByType['background']?.[0] as + | BackgroundEntrypoint + | undefined; + const bookmarks = entriesByType['bookmarks']?.[0]; + const contentScripts = entriesByType['content-script'] as + | ContentScriptEntrypoint[] + | undefined; + const devtools = entriesByType['devtools']?.[0]; + const history = entriesByType['history']?.[0]; + const newtab = entriesByType['newtab']?.[0]; + const options = entriesByType['options']?.[0] as + | OptionsEntrypoint + | undefined; + const popup = entriesByType['popup']?.[0] as PopupEntrypoint | undefined; + const sandboxes = entriesByType['sandbox']; + const sidepanels = entriesByType['sidepanel']; + + if (background) { + const script = getEntrypointBundlePath(background, config.outDir, '.js'); + if (manifest.manifest_version === 3) { + manifest.background = { + ...background.options, + service_worker: script, + }; + } else { + manifest.background = { + ...background.options, + scripts: [script], + }; + } + } + + if (bookmarks) { + if (config.browser === 'firefox') { + config.logger.warn( + 'Bookmarks are not supported by Firefox. chrome_url_overrides.bookmarks was not added to the manifest', + ); + } else { + manifest.chrome_url_overrides ??= {}; + // @ts-expect-error: bookmarks is untyped in webextension-polyfill, but supported by chrome + manifest.chrome_url_overrides.bookmarks = getEntrypointBundlePath( + bookmarks, + config.outDir, + '.html', + ); + } + } + + if (history) { + if (config.browser === 'firefox') { + config.logger.warn( + 'Bookmarks are not supported by Firefox. chrome_url_overrides.history was not added to the manifest', + ); + } else { + manifest.chrome_url_overrides ??= {}; + // @ts-expect-error: history is untyped in webextension-polyfill, but supported by chrome + manifest.chrome_url_overrides.history = getEntrypointBundlePath( + history, + config.outDir, + '.html', + ); + } + } + + if (newtab) { + manifest.chrome_url_overrides ??= {}; + manifest.chrome_url_overrides.newtab = getEntrypointBundlePath( + newtab, + config.outDir, + '.html', + ); + } + + if (popup) { + const default_popup = getEntrypointBundlePath( + popup, + config.outDir, + '.html', + ); + if (manifest.manifest_version === 3) { + manifest.action = { + ...popup.options, + default_popup, + }; + } else { + manifest[popup.options.mv2Key ?? 'browser_action'] = { + ...popup.options, + default_popup, + }; + } + } + + if (devtools) { + manifest.devtools_page = getEntrypointBundlePath( + devtools, + config.outDir, + '.html', + ); + } + + if (options) { + const page = getEntrypointBundlePath(options, config.outDir, '.html'); + manifest.options_ui = { + ...options.options, + page, + }; + } + + if (sandboxes?.length) { + if (config.browser === 'firefox') { + config.logger.warn( + 'Sandboxed pages not supported by Firefox. sandbox.pages was not added to the manifest', + ); + } else { + // @ts-expect-error: sandbox not typed + manifest.sandbox = { + pages: sandboxes.map((entry) => + getEntrypointBundlePath(entry, config.outDir, '.html'), + ), + }; + } + } + + if (sidepanels?.length) { + const defaultSidepanel = + sidepanels.find((entry) => entry.name === 'sidepanel') ?? sidepanels[0]; + const page = getEntrypointBundlePath( + defaultSidepanel, + config.outDir, + '.html', + ); + + if (config.browser === 'firefox') { + manifest.sidebar_action = { + // TODO: Add options to side panel + // ...defaultSidepanel.options, + default_panel: page, + }; + } else if (config.browser === 'chromium' && config.manifestVersion === 3) { + // @ts-expect-error: Untyped + manifest.side_panel = { + default_path: page, + }; + } else if (config.browser === 'chromium') { + config.logger.warn( + 'Side panel not supported by Chromium using MV2. side_panel.default_path was not added to the manifest', + ); + } + } + + if (contentScripts?.length) { + const hashToEntrypointsMap = contentScripts.reduce< + Record + >((map, script) => { + const hash = JSON.stringify(script.options); + map[hash] ??= []; + map[hash].push(script); + return map; + }, {}); + + manifest.content_scripts = Object.entries(hashToEntrypointsMap).map( + ([, scripts]) => ({ + ...scripts[0].options, + css: getContentScriptCssFiles(scripts, buildOutput), + js: scripts.map((entry) => + getEntrypointBundlePath(entry, config.outDir, '.js'), + ), + }), + ); + } + + return manifest; +} + +/** + * Read the package.json from the current directory. + * + * TODO: look in root and up directories until it's found + */ +async function getPackageJson(): Promise { + return await fs.readJson('package.json'); +} + +/** + * Removes suffixes from the version, like X.Y.Z-alpha1 (which brosers don't allow), so it's a + * simple version number, like X or X.Y or X.Y.Z, which browsers allow. + */ +function simplifyVersion(versionName: string): string { + // Regex adapted from here: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/version#version_format + + const version = /^((0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}).*$/.exec( + versionName, + )?.[1]; + + if (version == null) + throw Error( + `Cannot simplify package.json version "${versionName}" to a valid extension version, "X.Y.Z"`, + ); + + return version; +} + +/** + * Returns the bundle paths to CSS files associated with a list of content scripts, or undefined if + * there is no associated CSS. + */ +function getContentScriptCssFiles( + contentScripts: ContentScriptEntrypoint[], + buildOutput: BuildOutput, +): string[] | undefined { + console.warn('TODO: getContentScriptCssFiles'); + return undefined; +} diff --git a/src/utils/printBuildSummary.ts b/src/utils/printBuildSummary.ts new file mode 100644 index 00000000..129f7476 --- /dev/null +++ b/src/utils/printBuildSummary.ts @@ -0,0 +1,51 @@ +import path, { extname, relative, resolve } from 'path'; +import { BuildOutput, InternalConfig } from '../types'; +import { printTable } from './printTable'; +import pc from 'picocolors'; +import fs from 'fs-extra'; +import { filesize } from 'filesize'; + +export async function printBuildSummary( + output: BuildOutput, + config: InternalConfig, +) { + const chunks = output + .flatMap(({ output }) => output) + .sort((l, r) => { + const lWeight = + CHUNK_SORT_WEIGHTS[extname(l.fileName)] ?? DEFAULT_SORT_WEIGHT; + const rWeight = + CHUNK_SORT_WEIGHTS[extname(r.fileName)] ?? DEFAULT_SORT_WEIGHT; + return lWeight - rWeight; + }); + + const chunkRows: string[][] = await Promise.all( + chunks.map(async (chunk) => { + const file = [ + relative(process.cwd(), config.outDir) + path.sep, + chunk.fileName, + ]; + const ext = extname(chunk.fileName); + const color = CHUNK_COLORS[ext] ?? DEFAULT_COLOR; + const stats = await fs.lstat(resolve(config.outDir, chunk.fileName)); + const size = String(filesize(stats.size)); + return [`${pc.dim(file[0])}${color(file[1])}`, pc.dim(size)]; + }), + ); + + printTable(config.logger.log, [['Chunks'], ...chunkRows]); +} + +const DEFAULT_SORT_WEIGHT = 100; +const CHUNK_SORT_WEIGHTS: Record = { + '.html': 0, + '.js': 1, + '.css': 2, +}; + +const DEFAULT_COLOR = pc.blue; +const CHUNK_COLORS: Record string> = { + '.html': pc.green, + '.css': pc.magenta, + '.js': pc.cyan, +}; diff --git a/src/utils/printTable.ts b/src/utils/printTable.ts new file mode 100644 index 00000000..986bd2d8 --- /dev/null +++ b/src/utils/printTable.ts @@ -0,0 +1,28 @@ +export function printTable( + log: (message: string) => void, + rows: string[][], + gap = 2, +): void { + if (rows.length === 0) return; + + const columnWidths = rows.reduce( + (widths, row) => { + for (let i = 0; i < Math.max(widths.length, row.length); i++) { + widths[i] = Math.max(row[i]?.length ?? 0, widths[i] ?? 0); + } + return widths; + }, + rows[0].map((column) => column.length), + ); + + let str = ''; + rows.forEach((row, i) => { + row.forEach((col, j) => { + str += col.padEnd(columnWidths[j], ' '); + if (j !== row.length - 1) str += ''.padEnd(gap, ' '); + }); + str += '\n'; + }); + + log(str); +} diff --git a/src/utils/removeEmptyDirs.ts b/src/utils/removeEmptyDirs.ts new file mode 100644 index 00000000..ca607dfe --- /dev/null +++ b/src/utils/removeEmptyDirs.ts @@ -0,0 +1,19 @@ +import fs from 'fs-extra'; +import path from 'path'; + +export async function removeEmptyDirs(dir: string): Promise { + const files = await fs.readdir(dir); + for (const file of files) { + const filePath = path.join(dir, file); + const stats = await fs.stat(filePath); + if (stats.isDirectory()) { + await removeEmptyDirs(filePath); + } + } + + try { + await fs.rmdir(dir); + } catch { + // noop on failure - this means the directory was not empty. + } +} diff --git a/src/utils/trimExtension.ts b/src/utils/trimExtension.ts new file mode 100644 index 00000000..a627d867 --- /dev/null +++ b/src/utils/trimExtension.ts @@ -0,0 +1,10 @@ +import path from 'node:path'; + +export function trimExtension(filename: string): string; +export function trimExtension(filename: undefined): undefined; +export function trimExtension(filename: string | undefined): string | undefined; +export function trimExtension( + filename: string | undefined, +): string | undefined { + return filename?.replace(path.extname(filename), ''); +} diff --git a/src/vite-plugins/download.ts b/src/vite-plugins/download.ts index d3766ce0..06aa9ee4 100644 --- a/src/vite-plugins/download.ts +++ b/src/vite-plugins/download.ts @@ -1,4 +1,5 @@ import { Plugin } from 'vite'; +import { InternalConfig } from '../types'; /** * Downloads any URL imports into virtual modules, like Google Analytics, so they are bundled with @@ -7,8 +8,8 @@ import { Plugin } from 'vite'; * @example * import "https://google-tagmanager.com/gtag?id=XYZ"; */ -export function download(): Plugin { - console.warn('Not implemented: download plugin'); +export function download(config: InternalConfig): Plugin { + config.logger.warn('Not implemented: download plugin'); return { name: 'exvite:download', }; diff --git a/src/vite-plugins/index.ts b/src/vite-plugins/index.ts index 326774ee..42b30e97 100644 --- a/src/vite-plugins/index.ts +++ b/src/vite-plugins/index.ts @@ -1,2 +1,3 @@ export * from './download'; export * from './unimport'; +export * from './multipageMove'; diff --git a/src/vite-plugins/multipageMove.ts b/src/vite-plugins/multipageMove.ts new file mode 100644 index 00000000..616d5bd4 --- /dev/null +++ b/src/vite-plugins/multipageMove.ts @@ -0,0 +1,67 @@ +import * as vite from 'vite'; +import { Entrypoint, InternalConfig } from '../types'; +import { dirname, extname, resolve } from 'node:path'; +import { getEntrypointBundlePath } from '../utils/entrypoints'; +import fs, { ensureDir } from 'fs-extra'; + +/** + * Ensures the HTML files output by a multipage build are in the correct location. This does two + * things: + * + * 1. Moves the HMTL files to their final location at `/.html`. + * 2. Updates the bundle so it summarizes the files correctly in the returned build output. + * + * Assets (JS and CSS) are output to the `/assets` directory, and don't need to be modified. + * HTML files access them via absolute URLs, so we don't need to update any import paths in the HTML + * files either. + */ +export function multipageMove( + entrypoints: Entrypoint[], + config: InternalConfig, +): vite.Plugin { + return { + name: 'exvite:multipage-move', + async writeBundle(_, bundle) { + for (const oldBundlePath in bundle) { + // oldBundlePath = 'entrypoints/popup.html' or 'entrypoints/o ptions/index.html' + + // Find a matching entrypoint - oldBundlePath is the same as end end of the input path. + const entrypoint = entrypoints.find( + (entry) => !!entry.inputPath.endsWith(oldBundlePath), + ); + if (entrypoint == null) { + config.logger.debug('No entrypoint found for', oldBundlePath); + continue; + } + + // Get the new bundle path + const newBundlePath = getEntrypointBundlePath( + entrypoint, + config.outDir, + extname(oldBundlePath), + ); + if (newBundlePath === oldBundlePath) { + config.logger.debug( + 'HTML file is already in the correct location', + oldBundlePath, + ); + continue; + } + + // Move file and update bundle + // Do this inside a mutex lock so it only runs one at a time for concurrent multipage builds + const oldAbsPath = resolve(config.outDir, oldBundlePath); + const newAbsPath = resolve(config.outDir, newBundlePath); + await ensureDir(dirname(newAbsPath)); + await fs.move(oldAbsPath, newAbsPath); + + const renamedChunk = { + ...bundle[oldBundlePath], + fileName: newBundlePath, + }; + delete bundle[oldBundlePath]; + bundle[newBundlePath] = renamedChunk; + } + }, + }; +} diff --git a/src/vite-plugins/unimport.ts b/src/vite-plugins/unimport.ts index 7544812b..d727f6df 100644 --- a/src/vite-plugins/unimport.ts +++ b/src/vite-plugins/unimport.ts @@ -2,6 +2,7 @@ import Unimport, { UnimportPluginOptions } from 'unimport/unplugin'; import { UserConfig } from '../types'; import path from 'node:path'; import * as vite from 'vite'; +import fs from 'fs-extra'; /** * Apply defaults and returns an instance of the unimport plugin. @@ -15,12 +16,16 @@ export function unimport( srcDir: string, imports: UserConfig['imports'], ) { + const declartionFile = path.resolve(root, '.exvite/types/imports.d.ts'); + const declarationDir = path.dirname(declartionFile); + fs.ensureDirSync(declarationDir); + const defaultOptions: UnimportPluginOptions = { include: srcDir, exclude: [], addons: [], debugLog: () => {}, - dts: path.resolve(root, '.exvite/types/imports.d.ts'), + dts: declartionFile, imports: [{ name: '*', as: 'browser', from: 'webextension-polyfill' }], presets: [ // Scan for exported functions from the client package diff --git a/tsconfig.base.json b/tsconfig.base.json index 160ea697..01ef1294 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -10,6 +10,9 @@ /* Type Checking */ "strict": true, + /* Linting */ + "noUnusedLocals": true, + /* Completeness */ "skipLibCheck": true }