From 21636defdaeb41afc3a34ad77d847f60962212b4 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sun, 4 Feb 2024 17:37:31 -0600 Subject: [PATCH] feat: Hooks (#419) Deprecated `transformManifest` since a hook now exists for it. --- e2e/tests/hooks.test.ts | 99 +++++++++++++++++++++ e2e/utils.ts | 35 ++++++-- package.json | 1 + pnpm-lock.yaml | 7 ++ src/core/utils/building/find-entrypoints.ts | 2 + src/core/utils/building/internal-build.ts | 5 ++ src/core/utils/building/resolve-config.ts | 6 ++ src/core/utils/manifest.ts | 1 + src/core/wxt.ts | 9 +- src/types/index.ts | 53 +++++++++++ 10 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 e2e/tests/hooks.test.ts diff --git a/e2e/tests/hooks.test.ts b/e2e/tests/hooks.test.ts new file mode 100644 index 000000000..46b88bcf4 --- /dev/null +++ b/e2e/tests/hooks.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TestProject } from '../utils'; +import { WxtHooks } from '~/types'; + +const hooks: WxtHooks = { + ready: vi.fn(), + 'build:before': vi.fn(), + 'build:done': vi.fn(), + 'build:manifestGenerated': vi.fn(), + 'entrypoints:resolved': vi.fn(), + 'entrypoints:grouped': vi.fn(), +}; + +function expectHooksToBeCalled(called: Record) { + Object.keys(hooks).forEach((key) => { + const hookName = key as keyof WxtHooks; + const times = called[hookName] ? 1 : 0; + expect( + hooks[hookName], + `Expected "${hookName}" to be called ${times} time(s)`, + ).toBeCalledTimes(called[hookName] ? 1 : 0); + }); +} + +describe('Hooks', () => { + beforeEach(() => { + Object.values(hooks).forEach((fn) => fn.mockReset()); + }); + + it('prepare should call hooks', async () => { + const project = new TestProject(); + project.addFile('entrypoints/popup.html', ''); + + await project.prepare({ hooks }); + + expectHooksToBeCalled({ + ready: true, + 'build:before': false, + 'build:done': false, + 'build:manifestGenerated': false, + 'entrypoints:grouped': false, + 'entrypoints:resolved': true, + }); + }); + + it('build should call hooks', async () => { + const project = new TestProject(); + project.addFile('entrypoints/popup.html', ''); + + await project.build({ hooks }); + + expectHooksToBeCalled({ + ready: true, + 'build:before': true, + 'build:done': true, + 'build:manifestGenerated': true, + 'entrypoints:grouped': true, + 'entrypoints:resolved': true, + }); + }); + + it('zip should call hooks', async () => { + const project = new TestProject(); + project.addFile('entrypoints/popup.html', ''); + + await project.zip({ hooks }); + + expectHooksToBeCalled({ + ready: true, + 'build:before': true, + 'build:done': true, + 'build:manifestGenerated': true, + 'entrypoints:grouped': true, + 'entrypoints:resolved': true, + }); + }); + + it('server.start should call hooks', async () => { + const project = new TestProject(); + project.addFile('entrypoints/popup.html', ''); + + const server = await project.startServer({ + hooks, + runner: { + disabled: true, + }, + }); + await server.stop(); + + expectHooksToBeCalled({ + ready: true, + 'build:before': true, + 'build:done': true, + 'build:manifestGenerated': true, + 'entrypoints:grouped': true, + 'entrypoints:resolved': true, + }); + }); +}); diff --git a/e2e/utils.ts b/e2e/utils.ts index 0144798a8..ea153508c 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -2,7 +2,14 @@ import { dirname, join, relative, resolve } from 'path'; import fs from 'fs-extra'; import glob from 'fast-glob'; import { execaCommand } from 'execa'; -import { InlineConfig, UserConfig, build } from '../src'; +import { + InlineConfig, + UserConfig, + build, + createServer, + prepare, + zip, +} from '../src'; import { normalizePath } from '../src/core/utils/paths'; import merge from 'lodash.merge'; @@ -63,10 +70,29 @@ export class TestProject { if (filename === 'wxt.config.ts') this.config = {}; } - /** - * Write the files to the test directory install dependencies, and build the project. - */ + async prepare(config: InlineConfig = {}) { + await this.writeProjectToDisk(); + await prepare({ ...config, root: this.root }); + } + async build(config: InlineConfig = {}) { + await this.writeProjectToDisk(); + await build({ ...config, root: this.root }); + } + + async zip(config: InlineConfig = {}) { + await this.writeProjectToDisk(); + await zip({ ...config, root: this.root }); + } + + async startServer(config: InlineConfig = {}) { + await this.writeProjectToDisk(); + const server = await createServer({ ...config, root: this.root }); + await server.start(); + return server; + } + + private async writeProjectToDisk() { if (this.config == null) this.setConfigFileConfig(); for (const file of this.files) { @@ -80,7 +106,6 @@ export class TestProject { await execaCommand('pnpm --ignore-workspace i --ignore-scripts', { cwd: this.root, }); - await build({ ...config, root: this.root }); } /** diff --git a/package.json b/package.json index ee1291a11..cc645e657 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "fs-extra": "^11.1.1", "get-port": "^7.0.0", "giget": "^1.1.3", + "hookable": "^5.5.3", "immer": "^10.0.3", "is-wsl": "^3.0.0", "jiti": "^1.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4451e77b7..c9b482f07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: giget: specifier: ^1.1.3 version: 1.1.3 + hookable: + specifier: ^5.5.3 + version: 5.5.3 immer: specifier: ^10.0.3 version: 10.0.3 @@ -2847,6 +2850,10 @@ packages: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} dev: false + /hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + dev: false + /hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} dev: true diff --git a/src/core/utils/building/find-entrypoints.ts b/src/core/utils/building/find-entrypoints.ts index 8a5d7f6b8..5f144be61 100644 --- a/src/core/utils/building/find-entrypoints.ts +++ b/src/core/utils/building/find-entrypoints.ts @@ -149,6 +149,8 @@ export async function findEntrypoints(): Promise { return true; }); wxt.logger.debug(`${wxt.config.browser} entrypoints:`, targetEntrypoints); + await wxt.hooks.callHook('entrypoints:resolved', wxt, targetEntrypoints); + return targetEntrypoints; } diff --git a/src/core/utils/building/internal-build.ts b/src/core/utils/building/internal-build.ts index 2405a4738..b61866091 100644 --- a/src/core/utils/building/internal-build.ts +++ b/src/core/utils/building/internal-build.ts @@ -30,6 +30,8 @@ import { wxt } from '../../wxt'; * 3. Prints the summary */ export async function internalBuild(): Promise { + await wxt.hooks.callHook('build:before', wxt); + const verb = wxt.config.command === 'serve' ? 'Pre-rendering' : 'Building'; const target = `${wxt.config.browser}-mv${wxt.config.manifestVersion}`; wxt.logger.info( @@ -57,7 +59,10 @@ export async function internalBuild(): Promise { } const groups = groupEntrypoints(entrypoints); + await wxt.hooks.callHook('entrypoints:grouped', wxt, groups); + const { output, warnings } = await rebuild(entrypoints, groups, undefined); + await wxt.hooks.callHook('build:done', wxt, output); // Post-build await printBuildSummary( diff --git a/src/core/utils/building/resolve-config.ts b/src/core/utils/building/resolve-config.ts index bc04c9e71..56eac56b4 100644 --- a/src/core/utils/building/resolve-config.ts +++ b/src/core/utils/building/resolve-config.ts @@ -140,6 +140,7 @@ export async function resolveConfig( dev: { reloadCommand, }, + hooks: mergedConfig.hooks ?? {}, }; const builder = await createViteBuilder( @@ -191,6 +192,10 @@ function mergeInlineConfig( inlineConfig.zip ?? {}, userConfig.zip ?? {}, ); + const hooks: InlineConfig['hooks'] = defu( + inlineConfig.hooks ?? {}, + userConfig.hooks ?? {}, + ); return { root: inlineConfig.root ?? userConfig.root, @@ -229,6 +234,7 @@ function mergeInlineConfig( ...userConfig.dev, ...inlineConfig.dev, }, + hooks, }; } diff --git a/src/core/utils/manifest.ts b/src/core/utils/manifest.ts index e3c601a28..3eab77d4a 100644 --- a/src/core/utils/manifest.ts +++ b/src/core/utils/manifest.ts @@ -114,6 +114,7 @@ export async function generateManifest( if (wxt.config.command === 'serve') addDevModePermissions(manifest); const finalManifest = produce(manifest, wxt.config.transformManifest); + await wxt.hooks.callHook('build:manifestGenerated', wxt, finalManifest); if (finalManifest.name == null) throw Error( diff --git a/src/core/wxt.ts b/src/core/wxt.ts index 1d96bec26..c7a9ab9b7 100644 --- a/src/core/wxt.ts +++ b/src/core/wxt.ts @@ -1,5 +1,6 @@ -import { InlineConfig, Wxt, WxtDevServer } from '~/types'; +import { InlineConfig, Wxt, WxtDevServer, WxtHooks } from '~/types'; import { resolveConfig } from './utils/building'; +import { createHooks } from 'hookable'; /** * Global variable set once `createWxt` is called once. Since this variable is used everywhere, this @@ -16,9 +17,11 @@ export async function registerWxt( server?: WxtDevServer, ): Promise { const config = await resolveConfig(inlineConfig, command, server); + const hooks = createHooks(); wxt = { config, + hooks, get logger() { return config.logger; }, @@ -26,6 +29,10 @@ export async function registerWxt( wxt.config = await resolveConfig(inlineConfig, command, server); }, }; + + // Initialize hooks + wxt.hooks.addHooks(config.hooks); + await wxt.hooks.callHook('ready', wxt); } /** diff --git a/src/types/index.ts b/src/types/index.ts index 987f4fc42..10812ca6e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,6 +6,7 @@ import { ContentScriptContext } from '../client/content-scripts/content-script-c import type { PluginVisualizerOptions } from 'rollup-plugin-visualizer'; import type { FSWatcher } from 'chokidar'; import { ResolvedConfig as C12ResolvedConfig } from 'c12'; +import { Hookable, NestedHooks } from 'hookable'; export interface InlineConfig { /** @@ -173,6 +174,9 @@ export interface InlineConfig { }; /** + * @deprecated Use `hooks.build.manifestGenerated` to modify your manifest instead. This option + * will be removed in v1.0 + * * Transform the final manifest before it's written to the file system. Edit the `manifest` * parameter directly, do not return a new object. Return values are ignored. * @@ -261,6 +265,10 @@ export interface InlineConfig { */ reloadCommand?: string | false; }; + /** + * Project hooks for running logic during the build process. + */ + hooks?: NestedHooks; } // TODO: Extract to @wxt/vite-builder and use module augmentation to include the vite field @@ -797,8 +805,52 @@ export interface ServerInfo { export type HookResult = Promise | void; +export interface WxtHooks { + /** + * Called after WXT initialization, when the WXT instance is ready to work. + * @param wxt The configured WXT object + * @returns Promise + */ + ready: (wxt: Wxt) => HookResult; + /** + * Called before the build is started in both dev mode and build mode. + * + * @param wxt The configured WXT object + */ + 'build:before': (wxt: Wxt) => HookResult; + /** + * Called once the build process has finished. + * @param wxt The configured WXT object + * @param output The results of the build + */ + 'build:done': (wxt: Wxt, output: Readonly) => HookResult; + /** + * Called once the manifest has been generated. Used to transform the manifest by reference before + * it is written to the output directory. + * @param wxt The configured WXT object + * @param manifest The manifest that was generated + */ + 'build:manifestGenerated': ( + wxt: Wxt, + manifest: Manifest.WebExtensionManifest, + ) => HookResult; + /** + * Called once all entrypoints have been loaded from the `entrypointsDir`. + * @param wxt The configured WXT object + * @param entrypoints The list of entrypoints to be built + */ + 'entrypoints:resolved': (wxt: Wxt, entrypoints: Entrypoint[]) => HookResult; + /** + * Called once all entrypoints have been grouped into their build groups. + * @param wxt The configured WXT object + * @param entrypoints The list of groups to build in each build step + */ + 'entrypoints:grouped': (wxt: Wxt, groups: EntrypointGroup[]) => HookResult; +} + export interface Wxt { config: ResolvedConfig; + hooks: Hookable; /** * Alias for config.logger */ @@ -856,6 +908,7 @@ export interface ResolvedConfig { dev: { reloadCommand: string | false; }; + hooks: Partial; } export interface FsCache {