From b3ddab7bcbe87c2484d7fd7b01e83e66cf31c261 Mon Sep 17 00:00:00 2001 From: Aaron Date: Tue, 11 Jun 2024 17:45:34 -0500 Subject: [PATCH] feat: Module system (#672) --- docs/.vitepress/config.ts | 1 + .../entrypoints/unlisted-pages.md | 2 +- docs/guide/go-further/reusable-modules.md | 186 +++++++++++ packages/wxt-demo/src/modules/example.ts | 19 ++ packages/wxt-demo/wxt.config.ts | 5 + packages/wxt/e2e/tests/analysis.test.ts | 20 +- packages/wxt/e2e/tests/auto-imports.test.ts | 3 + packages/wxt/e2e/tests/hooks.test.ts | 5 + packages/wxt/e2e/tests/modules.test.ts | 291 ++++++++++++++++++ .../wxt/e2e/tests/output-structure.test.ts | 26 +- .../wxt/e2e/tests/typescript-project.test.ts | 18 +- packages/wxt/e2e/tests/user-config.test.ts | 2 +- packages/wxt/e2e/utils.ts | 4 +- packages/wxt/package.json | 10 + packages/wxt/scripts/build.ts | 6 +- packages/wxt/src/__tests__/modules.test.ts | 40 +++ packages/wxt/src/core/builders/vite/index.ts | 1 + .../src/core/builders/vite/plugins/index.ts | 1 + .../builders/vite/plugins/wxtPluginLoader.ts | 62 ++++ .../src/core/utils/__tests__/strings.test.ts | 1 + .../core/utils/building/build-entrypoints.ts | 24 +- .../src/core/utils/building/resolve-config.ts | 61 ++++ .../src/core/utils/testing/fake-objects.ts | 3 + packages/wxt/src/core/wxt.ts | 14 + packages/wxt/src/modules.ts | 130 ++++++++ packages/wxt/src/sandbox/define-wxt-plugin.ts | 5 + packages/wxt/src/sandbox/index.ts | 1 + packages/wxt/src/types/index.ts | 94 +++++- .../wxt/src/virtual/background-entrypoint.ts | 2 + ...ontent-script-isolated-world-entrypoint.ts | 2 + .../content-script-main-world-entrypoint.ts | 2 + .../src/virtual/unlisted-script-entrypoint.ts | 2 + .../src/virtual/virtual-module-globals.d.ts | 4 + packages/wxt/typedoc.json | 3 +- 34 files changed, 1007 insertions(+), 43 deletions(-) create mode 100644 docs/guide/go-further/reusable-modules.md create mode 100644 packages/wxt-demo/src/modules/example.ts create mode 100644 packages/wxt/e2e/tests/modules.test.ts create mode 100644 packages/wxt/src/__tests__/modules.test.ts create mode 100644 packages/wxt/src/core/builders/vite/plugins/wxtPluginLoader.ts create mode 100644 packages/wxt/src/modules.ts create mode 100644 packages/wxt/src/sandbox/define-wxt-plugin.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 252ca997..b8d1137c 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -140,6 +140,7 @@ export default defineConfig({ menuItem('Handling Updates', 'handling-updates'), menuItem('Vite', 'vite'), menuItem('Custom Events', 'custom-events'), + menuItem('Reusable Modules', 'reusable-modules'), menuItem('Remote Code', 'remote-code'), menuItem('Entrypoint Side Effects', 'entrypoint-side-effects'), menuItem('How WXT Works', 'how-wxt-works'), diff --git a/docs/guide/directory-structure/entrypoints/unlisted-pages.md b/docs/guide/directory-structure/entrypoints/unlisted-pages.md index 2461908c..c9812e99 100644 --- a/docs/guide/directory-structure/entrypoints/unlisted-pages.md +++ b/docs/guide/directory-structure/entrypoints/unlisted-pages.md @@ -24,7 +24,7 @@ If you plan on using the page in an iframe, don't forget to add the page to [`we Pages are accessible at `'/.html'`: ```ts -const url = browser.runtime.getURL('/.html'); +const url = browser.runtime.getURL('/.html', ''); console.log(url); // "chrome-extension:///.html" ``` diff --git a/docs/guide/go-further/reusable-modules.md b/docs/guide/go-further/reusable-modules.md new file mode 100644 index 00000000..87f7bfde --- /dev/null +++ b/docs/guide/go-further/reusable-modules.md @@ -0,0 +1,186 @@ +--- +outline: deep +--- + +# Reusable Modules + +## Overview + +WXT provides a "module" API that lets you modify the build process. This API lets you add entrypoints, inject runtime code, add vite plugins, and more! + +What's more, these modules can be shared on NPM and re-used between projects! + +## Adding a Module + +There are two ways to add a module to your project: + +1. **Local file**: Any file present in the `modules/` directory will be treated as a module and loaded at build-time by WXT. You can use `modules/*.ts` or `modules/*/index.ts`, similar to entrypoints. + + ```ts + // modules/example.ts + import { defineWxtModule } from 'wxt/modules'; + + export default defineWxtModule((wxt) => { + // ... + }); + ``` + +2. **NPM package**: By convention, any package on NPM that starts with `@wxt-dev/module-*` or `wxt-module-*` can be installed and included in your project: + ```ts + // wxt.config.ts + export default defineConfig({ + // Add the module to your project + modules: ['wxt-module-example'], + }); + ``` + +## Writing Modules + +Modules contain a setup function that is executed at the beginning of the build process. + +:::code-group + +```ts [Function Definition] +import { defineWxtModule } from 'wxt/modules'; + +export default defineWxtModule((wxt) => { + // ... +}); +``` + +```ts [Object Definition] +import { defineWxtModule } from 'wxt/modules'; + +export default defineWxtModule({ + // Add metadata... + setup(wxt) { + // ... + }, +}); +``` + +::: + +### Module Options + +You can define custom options for your module by setting the `configKey`: + +```ts +// modules/analytics.ts +import { defineWxtModule } from 'wxt/modules'; + +export default defineWxtModule({ + configKey: 'analytics', + setup(wxt, options) { + console.log(options); // { clientId: "..." } + }, +}); + +// Define the option types +export interface AnalyticsModuleOptions { + clientId: string; +} + +// Use "module augmentation" to add types for the new key +declare module 'wxt' { + export interface InlineConfig { + analytics: AnalyticsModuleOptions; + } +} +``` + +Now, when the user provides options to the `analytics` key in their `wxt.config.ts`, those options are passed into the setup function as the second argument. + +```ts +export default defineConfig({ + analytics: { clientId: '...' }, +}); +``` + +### Actually Doing Something + +The first argument of the setup function, `wxt`, provides full access to the current build's context. You can access the resolved configuration via `wxt.config`, or setup hooks to manipulate the build at different steps of the build process. + +Here's an example that shows you how to update the `outDir` based on the build mode. It does a good job showing how to access config, set up a hook, and do something when the hook is called. + +```ts +export default defineWxtModule((wxt) => { + if (wxt.config.mode === 'development') { + // Use the "ready" hook to update wxt.config + wxt.hooks.hook('ready', (wxt) => { + wxt.config.outDir = wxt.config.outDir.replace('.output', '.output/dev'); + }); + } +}); +``` + +:::info Async Modules +Both the `setup` function and hook callbacks can be async. Don't forget to add `await`! +::: + +It's important to understand the basics of how hooks work. Make sure to read the [API reference](/api/reference/wxt/interfaces/WxtHooks.html) for the full list of hooks and what they should be used for. They are the key to modifying your extension. + +### Module Utils + +Additionally, WXT provides several helper functions that setup hooks behind the scenes to streamline common operations. + +For example, if you want to include an entrypoint from inside a module, you can use the `addEntrypoint` util: + +```ts +// modules/changelog.ts +import { defineWxtModule, addEntrypoint } from 'wxt/modules'; +import { resolve } from 'node:path'; + +export default defineWxtModule({ + name: 'changelog', + setup(wxt) { + addEntrypoint(wxt, { + type: 'unlisted-page', + name: 'changelog', + // Point to the "modules/changelog.html" file + inputPath: resolve(__dirname, 'changelog.html'), + outputDir: wxt.config.outputDir, + options: {}, + }); + }, +}); +``` + +Refer to the [API reference](/api/reference/wxt/modules/#functions) for the full list of the utilities. + +## Plugins + +Whereas modules are executed at build-time, plugins are executed at runtime. As of now, the only way to add a plugin is with the `addWxtPlugin` helper inside a module. + +Here's a minimal example to execute something at runtime. + +:::code-group + +```ts [modules/example/index.ts] +import { defineWxtModule, addWxtPlugin } from 'wxt/modules'; +import { resolve } from 'node:path'; + +export default defineWxtModule((wxt) => { + addWxtPlugin(wxt, resolve(__dirname, 'plugin.ts')); +}); +``` + +```ts [modules/example/plugin.ts] +import { defineWxtPlugin } from 'wxt/sandbox'; + +export default defineWxtPlugin(() => { + console.log('Executing plugin!'); +}); +``` + +::: + +:::warning Async Plugins +Unlike modules, **_plugins cannot be async_**!! If you need to do some async work and expose that result to the rest of the extension, wrap it in an object and handle awaiting the promise internally. +::: + +## Publishing to NPM + +:::warning 🚧 Under construction +These docs will be coming soon! +::: diff --git a/packages/wxt-demo/src/modules/example.ts b/packages/wxt-demo/src/modules/example.ts new file mode 100644 index 00000000..8ac1cf51 --- /dev/null +++ b/packages/wxt-demo/src/modules/example.ts @@ -0,0 +1,19 @@ +import { defineWxtModule } from 'wxt/modules'; + +// Example of adding option types to wxt config file +export interface ExampleModuleOptions { + a: string; + b?: string; +} +declare module 'wxt' { + interface InlineConfig { + example?: ExampleModuleOptions; + } +} + +export default defineWxtModule({ + configKey: 'example', + setup(wxt, options) { + wxt.logger.info('Example module with options:', options); + }, +}); diff --git a/packages/wxt-demo/wxt.config.ts b/packages/wxt-demo/wxt.config.ts index 5515a36a..228b034e 100644 --- a/packages/wxt-demo/wxt.config.ts +++ b/packages/wxt-demo/wxt.config.ts @@ -21,4 +21,9 @@ export default defineConfig({ experimental: { viteRuntime: true, }, + example: { + a: 'a', + // @ts-expect-error: c is not defined, this should error out + c: 'c', + }, }); diff --git a/packages/wxt/e2e/tests/analysis.test.ts b/packages/wxt/e2e/tests/analysis.test.ts index 9770fe2d..c48fdc5b 100644 --- a/packages/wxt/e2e/tests/analysis.test.ts +++ b/packages/wxt/e2e/tests/analysis.test.ts @@ -17,8 +17,8 @@ describe('Analysis', () => { it('should output a stats.html with no part files by default', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html'); - project.addFile('entrypoints/options.html'); + project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/options.html', ''); project.addFile( 'entrypoints/background.ts', 'export default defineBackground(() => {});', @@ -38,8 +38,8 @@ describe('Analysis', () => { it('should save part files when requested', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html'); - project.addFile('entrypoints/options.html'); + project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/options.html', ''); project.addFile( 'entrypoints/background.ts', 'export default defineBackground(() => {});', @@ -59,8 +59,8 @@ describe('Analysis', () => { it('should support customizing the stats output directory', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html'); - project.addFile('entrypoints/options.html'); + project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/options.html', ''); project.addFile( 'entrypoints/background.ts', 'export default defineBackground(() => {});', @@ -78,8 +78,8 @@ describe('Analysis', () => { it('should place artifacts next to the custom output file', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html'); - project.addFile('entrypoints/options.html'); + project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/options.html', ''); project.addFile( 'entrypoints/background.ts', 'export default defineBackground(() => {});', @@ -100,8 +100,8 @@ describe('Analysis', () => { it('should open the stats in the browser when requested', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html'); - project.addFile('entrypoints/options.html'); + project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/options.html', ''); project.addFile( 'entrypoints/background.ts', 'export default defineBackground(() => {});', diff --git a/packages/wxt/e2e/tests/auto-imports.test.ts b/packages/wxt/e2e/tests/auto-imports.test.ts index 2c6619d8..e8d2e94b 100644 --- a/packages/wxt/e2e/tests/auto-imports.test.ts +++ b/packages/wxt/e2e/tests/auto-imports.test.ts @@ -27,6 +27,7 @@ describe('Auto Imports', () => { const defineConfig: typeof import('wxt')['defineConfig'] const defineContentScript: typeof import('wxt/sandbox')['defineContentScript'] const defineUnlistedScript: typeof import('wxt/sandbox')['defineUnlistedScript'] + const defineWxtPlugin: typeof import('wxt/sandbox')['defineWxtPlugin'] const fakeBrowser: typeof import('wxt/testing')['fakeBrowser'] const storage: typeof import('wxt/storage')['storage'] } @@ -124,6 +125,7 @@ describe('Auto Imports', () => { "defineConfig": true, "defineContentScript": true, "defineUnlistedScript": true, + "defineWxtPlugin": true, "fakeBrowser": true, "storage": true } @@ -163,6 +165,7 @@ describe('Auto Imports', () => { "defineConfig": "readonly", "defineContentScript": "readonly", "defineUnlistedScript": "readonly", + "defineWxtPlugin": "readonly", "fakeBrowser": "readonly", "storage": "readonly" } diff --git a/packages/wxt/e2e/tests/hooks.test.ts b/packages/wxt/e2e/tests/hooks.test.ts index f346fb4a..4958c3f7 100644 --- a/packages/wxt/e2e/tests/hooks.test.ts +++ b/packages/wxt/e2e/tests/hooks.test.ts @@ -7,6 +7,7 @@ const hooks: WxtHooks = { 'build:before': vi.fn(), 'build:done': vi.fn(), 'build:manifestGenerated': vi.fn(), + 'build:publicAssets': vi.fn(), 'entrypoints:resolved': vi.fn(), 'entrypoints:grouped': vi.fn(), 'vite:build:extendConfig': vi.fn(), @@ -42,6 +43,7 @@ describe('Hooks', () => { ready: true, 'build:before': false, 'build:done': false, + 'build:publicAssets': false, 'build:manifestGenerated': false, 'entrypoints:grouped': false, 'entrypoints:resolved': true, @@ -60,6 +62,7 @@ describe('Hooks', () => { ready: true, 'build:before': true, 'build:done': true, + 'build:publicAssets': true, 'build:manifestGenerated': true, 'entrypoints:grouped': true, 'entrypoints:resolved': true, @@ -78,6 +81,7 @@ describe('Hooks', () => { ready: true, 'build:before': true, 'build:done': true, + 'build:publicAssets': true, 'build:manifestGenerated': true, 'entrypoints:grouped': true, 'entrypoints:resolved': true, @@ -102,6 +106,7 @@ describe('Hooks', () => { ready: true, 'build:before': true, 'build:done': true, + 'build:publicAssets': true, 'build:manifestGenerated': true, 'entrypoints:grouped': true, 'entrypoints:resolved': true, diff --git a/packages/wxt/e2e/tests/modules.test.ts b/packages/wxt/e2e/tests/modules.test.ts new file mode 100644 index 00000000..f93ec59d --- /dev/null +++ b/packages/wxt/e2e/tests/modules.test.ts @@ -0,0 +1,291 @@ +import { describe, it, expect, vi } from 'vitest'; +import { TestProject } from '../utils'; +import type { GenericEntrypoint, InlineConfig } from '../../src/types'; +import { readFile } from 'fs-extra'; +import { normalizePath } from '~/core/utils/paths'; + +describe('Module Helpers', () => { + describe('options', () => { + it('should recieve the options defined in wxt.config.ts based on the configKey field', async () => { + const options = { key: '123' }; + const reportOptions = vi.fn(); + vi.stubGlobal('reportOptions', reportOptions); + const project = new TestProject(); + + project.addFile( + 'entrypoints/background.ts', + 'export default defineBackground(() => {})', + ); + project.addFile( + 'modules/test.ts', + ` + import { defineWxtModule } from 'wxt/modules'; + import { writeFile, mkdir } from 'node:fs/promises'; + import { resolve } from 'node:path'; + + export default defineWxtModule({ + configKey: "example", + setup: async (wxt, options) => { + reportOptions(options); + }, + }) + `, + ); + + await project.build({ + // @ts-expect-error: untyped field for testing + example: options, + }); + + expect(reportOptions).toBeCalledWith(options); + }); + }); + + describe('addEntrypoint', () => { + it('should add a custom entrypoint to be bundled the project', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/background.ts', + 'export default defineBackground(() => {})', + ); + + const entrypoint: GenericEntrypoint = { + type: 'unlisted-script', + inputPath: project.resolvePath('modules/test/injected.ts'), + name: 'injected', + options: {}, + outputDir: project.resolvePath('.output/chrome-mv3'), + skipped: false, + }; + project.addFile( + 'modules/test/injected.ts', + `export default defineUnlistedScript(() => {})`, + ); + project.addFile( + 'modules/test/index.ts', + ` + import { defineWxtModule, addEntrypoint } from 'wxt/modules'; + + export default defineWxtModule((wxt) => { + addEntrypoint(wxt, ${JSON.stringify(entrypoint)}) + }) + `, + ); + const config: InlineConfig = { + browser: 'chrome', + }; + + await project.build(config); + + expect(await project.fileExists('.output/chrome-mv3/injected.js')).toBe( + true, + ); + }); + }); + + describe('addPublicAssets', () => { + it('should add public assets', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/background.ts', + 'export default defineBackground(() => {})', + ); + + project.addFile('modules/test/public/module.txt'); + const dir = project.resolvePath('modules/test/public'); + project.addFile( + 'modules/test/index.ts', + ` + import { defineWxtModule, addPublicAssets } from 'wxt/modules' + import { resolve } from 'node:path' + + export default defineWxtModule((wxt) => { + addPublicAssets(wxt, "${normalizePath(dir)}") + }) + `, + ); + + const res = await project.build(); + + expect(res.publicAssets).toContainEqual({ + type: 'asset', + fileName: 'module.txt', + }); + await expect( + project.fileExists('.output/chrome-mv3/module.txt'), + ).resolves.toBe(true); + }); + + it("should not overwrite the user's public files", async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/background.ts', + 'export default defineBackground(() => {})', + ); + + project.addFile('public/user.txt', 'from-user'); + project.addFile('modules/test/public/user.txt', 'from-module'); + const dir = project.resolvePath('modules/test/public'); + project.addFile( + 'modules/test/index.ts', + ` + import { defineWxtModule, addPublicAssets } from 'wxt/modules' + import { resolve } from 'node:path' + + export default defineWxtModule((wxt) => { + addPublicAssets(wxt, "${normalizePath(dir)}") + }) + `, + ); + + const res = await project.build(); + + expect(res.publicAssets).toContainEqual({ + type: 'asset', + fileName: 'user.txt', + }); + await expect( + readFile(project.resolvePath('.output/chrome-mv3/user.txt'), 'utf8'), + ).resolves.toBe('from-user'); + }); + }); + + describe('addWxtPlugin', () => { + function addPluginModule(project: TestProject) { + const expectedText = 'Hello from plugin!'; + const pluginPath = project.addFile( + 'modules/test/client-plugin.ts', + ` + export default defineWxtPlugin(() => { + console.log("${expectedText}") + }) + `, + ); + project.addFile( + 'modules/test.ts', + ` + import { defineWxtModule, addWxtPlugin } from 'wxt/modules'; + + export default defineWxtModule((wxt) => { + addWxtPlugin(wxt, "${normalizePath(pluginPath)}"); + }); + `, + ); + return expectedText; + } + + it('should include the plugin in the background', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/background.ts', + 'export default defineBackground(() => {})', + ); + const expectedText = addPluginModule(project); + + await project.build({ + experimental: { + // reduce build output when comparing test failures + includeBrowserPolyfill: false, + }, + }); + + await expect(project.serializeOutput()).resolves.toContain(expectedText); + }); + + it('should include the plugin in HTML entrypoints', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/popup/index.html', + ` + + + + `, + ); + const expectedText = addPluginModule(project); + + await project.build({ + experimental: { + // reduce build output when comparing test failures + includeBrowserPolyfill: false, + }, + }); + + await expect(project.serializeOutput()).resolves.toContain(expectedText); + }); + + it('should include the plugin in content scripts', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/content.ts', + ` + export default defineContentScript({ + matches: ["*://*/*"], + main: () => {}, + }) + `, + ); + const expectedText = addPluginModule(project); + + await project.build({ + experimental: { + // reduce build output when comparing test failures + includeBrowserPolyfill: false, + }, + }); + + await expect(project.serializeOutput()).resolves.toContain(expectedText); + }); + + it('should include the plugin in unlisted scripts', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/unlisted.ts', + 'export default defineUnlistedScript(() => {})', + ); + const expectedText = addPluginModule(project); + + await project.build({ + experimental: { + // reduce build output when comparing test failures + includeBrowserPolyfill: false, + }, + }); + + await expect(project.serializeOutput()).resolves.toContain(expectedText); + }); + }); + + describe('imports', () => { + it('should add auto-imports', async () => { + const expectedText = 'customImport!'; + const project = new TestProject(); + project.addFile( + 'entrypoints/background.ts', + `export default defineBackground(() => { + customImport(); + });`, + ); + const utils = project.addFile( + 'custom.ts', + `export function customImport() { + console.log("${expectedText}") + }`, + ); + project.addFile( + 'modules/test.ts', + `import { defineWxtModule } from 'wxt/modules'; + + export default defineWxtModule({ + imports: [ + { name: 'customImport', from: '${normalizePath(utils)}' }, + ], + })`, + ); + + await project.build(); + + await expect(project.serializeOutput()).resolves.toContain(expectedText); + }); + }); +}); diff --git a/packages/wxt/e2e/tests/output-structure.test.ts b/packages/wxt/e2e/tests/output-structure.test.ts index b37dcf22..599b9df8 100644 --- a/packages/wxt/e2e/tests/output-structure.test.ts +++ b/packages/wxt/e2e/tests/output-structure.test.ts @@ -5,20 +5,26 @@ describe('Output Directory Structure', () => { it('should not output hidden files and directories that start with "."', async () => { const project = new TestProject(); project.addFile('entrypoints/.DS_Store'); - project.addFile('entrypoints/.hidden1/index.html'); - project.addFile('entrypoints/.hidden2.html'); - project.addFile('entrypoints/unlisted.html'); + project.addFile('entrypoints/.hidden1/index.html', ''); + project.addFile('entrypoints/.hidden2.html', ''); + project.addFile('entrypoints/unlisted.html', ''); await project.build(); expect(await project.serializeOutput()).toMatchInlineSnapshot(` - ".output/chrome-mv3/manifest.json + ".output/chrome-mv3/chunks/unlisted-DPbbfBKe.js + ---------------------------------------- + (function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))n(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function s(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function n(e){if(e.ep)return;e.ep=!0;const r=s(e);fetch(e.href,r)}})();try{}catch(i){console.error("[wxt] Failed to initialize plugins",i)} + + ================================================================================ + .output/chrome-mv3/manifest.json ---------------------------------------- {"manifest_version":3,"name":"E2E Extension","description":"Example description","version":"0.0.0"} ================================================================================ .output/chrome-mv3/unlisted.html ---------------------------------------- - " + + " `); }); @@ -159,7 +165,7 @@ describe('Output Directory Structure', () => { ); project.addFile('entrypoints/popup.html', ''); project.addFile( - 'entrypoints/overlay.content.html', + 'entrypoints/overlay.content.ts', `export default defineContentScript({ matches: [], main() {}, @@ -216,7 +222,7 @@ describe('Output Directory Structure', () => { it("should output to a custom directory when overriding 'outDir'", async () => { const project = new TestProject(); - project.addFile('entrypoints/unlisted.html'); + project.addFile('entrypoints/unlisted.html', ''); project.setConfigFileConfig({ outDir: 'dist', }); @@ -272,7 +278,7 @@ describe('Output Directory Structure', () => { .toMatchInlineSnapshot(` ".output/chrome-mv3/background.js ---------------------------------------- - import { l as logHello } from "./chunks/log-BsZv2eRn.js"; + import { l as logHello, i as initPlugins } from "./chunks/_virtual_wxt-plugins-OjKtWpmY.js"; function defineBackground(arg) { if (typeof arg === "function") return { main: arg }; @@ -296,6 +302,7 @@ describe('Output Directory Structure', () => { }; var result; try { + initPlugins(); result = definition.main(); if (result instanceof Promise) { console.warn( @@ -370,6 +377,8 @@ describe('Output Directory Structure', () => { } }); _background; + function initPlugins() { + } chrome; function print(method, ...args) { return; @@ -382,6 +391,7 @@ describe('Output Directory Structure', () => { }; var result; try { + initPlugins(); result = definition.main(); if (result instanceof Promise) { console.warn( diff --git a/packages/wxt/e2e/tests/typescript-project.test.ts b/packages/wxt/e2e/tests/typescript-project.test.ts index 511dd953..ce645112 100644 --- a/packages/wxt/e2e/tests/typescript-project.test.ts +++ b/packages/wxt/e2e/tests/typescript-project.test.ts @@ -4,7 +4,7 @@ import { TestProject } from '../utils'; describe('TypeScript Project', () => { it('should generate defined constants correctly', async () => { const project = new TestProject(); - project.addFile('entrypoints/unlisted.html'); + project.addFile('entrypoints/unlisted.html', ''); await project.prepare(); @@ -34,9 +34,9 @@ describe('TypeScript Project', () => { it('should augment the types for browser.runtime.getURL', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html'); - project.addFile('entrypoints/options.html'); - project.addFile('entrypoints/sandbox.html'); + project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/options.html', ''); + project.addFile('entrypoints/sandbox.html', ''); await project.prepare(); @@ -64,7 +64,7 @@ describe('TypeScript Project', () => { it('should augment the types for browser.i18n.getMessage', async () => { const project = new TestProject(); - project.addFile('entrypoints/unlisted.html'); + project.addFile('entrypoints/unlisted.html', ''); project.addFile( 'public/_locales/en/messages.json', JSON.stringify({ @@ -223,7 +223,7 @@ describe('TypeScript Project', () => { it('should reference all the required types in a single declaration file', async () => { const project = new TestProject(); - project.addFile('entrypoints/unlisted.html'); + project.addFile('entrypoints/unlisted.html', ''); await project.prepare(); @@ -243,7 +243,7 @@ describe('TypeScript Project', () => { it('should generate a TSConfig file for the project', async () => { const project = new TestProject(); - project.addFile('entrypoints/unlisted.html'); + project.addFile('entrypoints/unlisted.html', ''); await project.prepare(); @@ -284,7 +284,7 @@ describe('TypeScript Project', () => { it('should generate correct path aliases for a custom srcDir', async () => { const project = new TestProject(); - project.addFile('src/entrypoints/unlisted.html'); + project.addFile('src/entrypoints/unlisted.html', ''); project.setConfigFileConfig({ srcDir: 'src', }); @@ -328,7 +328,7 @@ describe('TypeScript Project', () => { it('should add additional path aliases listed in the alias config, preventing defaults from being overridden', async () => { const project = new TestProject(); - project.addFile('src/entrypoints/unlisted.html'); + project.addFile('src/entrypoints/unlisted.html', ''); project.setConfigFileConfig({ srcDir: 'src', alias: { diff --git a/packages/wxt/e2e/tests/user-config.test.ts b/packages/wxt/e2e/tests/user-config.test.ts index 1a40d1a8..9f8e7a79 100644 --- a/packages/wxt/e2e/tests/user-config.test.ts +++ b/packages/wxt/e2e/tests/user-config.test.ts @@ -61,7 +61,7 @@ describe('User Config', () => { it('should merge inline and user config based manifests', async () => { const project = new TestProject(); - project.addFile('entrypoints/unlisted.html'); + project.addFile('entrypoints/unlisted.html', ''); project.addFile( 'wxt.config.ts', `import { defineConfig } from 'wxt'; diff --git a/packages/wxt/e2e/utils.ts b/packages/wxt/e2e/utils.ts index d2d91e8e..a57943d7 100644 --- a/packages/wxt/e2e/utils.ts +++ b/packages/wxt/e2e/utils.ts @@ -71,10 +71,12 @@ export class TestProject { * * @param filename Filename relative to the project's root. * @param content File content. + * @returns The absolute path to the file that was added. */ addFile(filename: string, content?: string) { this.files.push([filename, content ?? '']); if (filename === 'wxt.config.ts') this.config = {}; + return this.resolvePath(filename); } async prepare(config: InlineConfig = {}) { @@ -84,7 +86,7 @@ export class TestProject { async build(config: InlineConfig = {}) { await this.writeProjectToDisk(); - await build({ ...config, root: this.root }); + return await build({ ...config, root: this.root }); } async zip(config: InlineConfig = {}) { diff --git a/packages/wxt/package.json b/packages/wxt/package.json index 83beccce..0713fe81 100644 --- a/packages/wxt/package.json +++ b/packages/wxt/package.json @@ -78,6 +78,16 @@ }, "./vite-builder-env": { "types": "./dist/vite-builder-env.d.ts" + }, + "./modules": { + "import": { + "types": "./dist/modules.d.ts", + "default": "./dist/modules.js" + }, + "require": { + "types": "./dist/modules.d.cts", + "default": "./dist/modules.cjs" + } } }, "scripts": { diff --git a/packages/wxt/scripts/build.ts b/packages/wxt/scripts/build.ts index d95b5dc6..21a94ab2 100644 --- a/packages/wxt/scripts/build.ts +++ b/packages/wxt/scripts/build.ts @@ -24,7 +24,10 @@ const preset = { dts: true, silent: true, sourcemap: false, - external: virtualEntrypointModuleNames.map((name) => `virtual:user-${name}`), + external: [ + ...virtualEntrypointModuleNames.map((name) => `virtual:user-${name}`), + 'virtual:wxt-plugins', + ], } satisfies tsup.Options; function spinnerPMap(configs: tsup.Options[]) { @@ -57,6 +60,7 @@ const config: tsup.Options[] = [ index: 'src/index.ts', testing: 'src/testing/index.ts', storage: 'src/storage.ts', + modules: 'src/modules.ts', }, format: ['cjs', 'esm'], clean: true, diff --git a/packages/wxt/src/__tests__/modules.test.ts b/packages/wxt/src/__tests__/modules.test.ts new file mode 100644 index 00000000..7e527728 --- /dev/null +++ b/packages/wxt/src/__tests__/modules.test.ts @@ -0,0 +1,40 @@ +import { fakeWxt } from '~/core/utils/testing/fake-objects'; +import { addViteConfig } from '../modules'; +import { describe, it, expect } from 'vitest'; +import { createHooks } from 'hookable'; + +describe('Module Utilities', () => { + describe('addViteConfig', () => { + it('should add base vite config', async () => { + const wxt = fakeWxt({ + hooks: createHooks(), + }); + const expected = { build: { sourcemap: true } }; + const userConfig = {}; + const moduleConfig = { build: { sourcemap: true } }; + + wxt.config.vite = () => userConfig; + addViteConfig(wxt, () => moduleConfig); + await wxt.hooks.callHook('ready', wxt); + const actual: any = wxt.config.vite(wxt.config.env); + + expect(actual).toEqual(expected); + }); + + it('should allow user config to override any changes made', async () => { + const wxt = fakeWxt({ + hooks: createHooks(), + }); + const expected = { build: { sourcemap: true, test: 2 } }; + const userConfig = { build: { sourcemap: true } }; + const moduleConfig = { build: { sourcemap: false, test: 2 } }; + + wxt.config.vite = () => userConfig; + addViteConfig(wxt, () => moduleConfig); + await wxt.hooks.callHook('ready', wxt); + const actual: any = wxt.config.vite(wxt.config.env); + + expect(actual).toEqual(expected); + }); + }); +}); diff --git a/packages/wxt/src/core/builders/vite/index.ts b/packages/wxt/src/core/builders/vite/index.ts index 33b5f309..b466295b 100644 --- a/packages/wxt/src/core/builders/vite/index.ts +++ b/packages/wxt/src/core/builders/vite/index.ts @@ -65,6 +65,7 @@ export async function createViteBuilder( wxtPlugins.globals(wxtConfig), wxtPlugins.excludeBrowserPolyfill(wxtConfig), wxtPlugins.defineImportMeta(), + wxtPlugins.wxtPluginLoader(wxtConfig), ); if (wxtConfig.analysis.enabled) { config.plugins.push(wxtPlugins.bundleAnalysis(wxtConfig)); diff --git a/packages/wxt/src/core/builders/vite/plugins/index.ts b/packages/wxt/src/core/builders/vite/plugins/index.ts index 5d390109..a81926b8 100644 --- a/packages/wxt/src/core/builders/vite/plugins/index.ts +++ b/packages/wxt/src/core/builders/vite/plugins/index.ts @@ -14,3 +14,4 @@ export * from './excludeBrowserPolyfill'; export * from './entrypointGroupGlobals'; export * from './defineImportMeta'; export * from './removeEntrypointMainFunction'; +export * from './wxtPluginLoader'; diff --git a/packages/wxt/src/core/builders/vite/plugins/wxtPluginLoader.ts b/packages/wxt/src/core/builders/vite/plugins/wxtPluginLoader.ts new file mode 100644 index 00000000..6e908f40 --- /dev/null +++ b/packages/wxt/src/core/builders/vite/plugins/wxtPluginLoader.ts @@ -0,0 +1,62 @@ +import { parseHTML } from 'linkedom'; +import type * as vite from 'vite'; +import { normalizePath } from '~/core/utils/paths'; +import { ResolvedConfig } from '~/types'; + +/** + * Resolve and load plugins for each entrypoint. This handles both JS entrypoints via the `virtual:wxt-plugins` import, and HTML files by adding `virtual:wxt-html-plugins` to the document's `` + */ +export function wxtPluginLoader(config: ResolvedConfig): vite.Plugin { + const virtualModuleId = 'virtual:wxt-plugins'; + const resolvedVirtualModuleId = '\0' + virtualModuleId; + const virtualHtmlModuleId = 'virtual:wxt-html-plugins'; + const resolvedVirtualHtmlModuleId = '\0' + virtualHtmlModuleId; + + return { + name: 'wxt:plugin-loader', + resolveId(id) { + if (id === virtualModuleId) return resolvedVirtualModuleId; + if (id === virtualHtmlModuleId) return resolvedVirtualHtmlModuleId; + }, + load(id) { + if (id === resolvedVirtualModuleId) { + // Import and init all plugins + const imports = config.plugins + .map( + (plugin, i) => + `import initPlugin${i} from '${normalizePath(plugin)}';`, + ) + .join('\n'); + const initCalls = config.plugins + .map((_, i) => ` initPlugin${i}();`) + .join('\n'); + return `${imports}\n\nexport function initPlugins() {\n${initCalls}\n}`; + } + if (id === resolvedVirtualHtmlModuleId) { + return `import { initPlugins } from '${virtualModuleId}'; + +try { + initPlugins(); +} catch (err) { + console.error("[wxt] Failed to initialize plugins", err); +}`; + } + }, + transformIndexHtml: { + // Use "pre" so the new script is added before vite bundles all the scripts + order: 'pre', + handler(html, _ctx) { + const { document } = parseHTML(html); + const script = document.createElement('script'); + script.type = 'module'; + script.src = 'virtual:wxt-html-plugins'; + if (document.head == null) { + const newHead = document.createElement('head'); + document.documentElement.prepend(newHead); + } + document.head.prepend(script); + return document.toString(); + }, + }, + }; +} diff --git a/packages/wxt/src/core/utils/__tests__/strings.test.ts b/packages/wxt/src/core/utils/__tests__/strings.test.ts index 8653650b..8974b64c 100644 --- a/packages/wxt/src/core/utils/__tests__/strings.test.ts +++ b/packages/wxt/src/core/utils/__tests__/strings.test.ts @@ -48,6 +48,7 @@ import{ registerGithubService, createGithubApi }from "@/utils/github"; import GitHub from "@/utils/github"; import "@/utils/github"; import '@/utils/github'; +import * as abc from "@/utils/github" import"@/utils/github" import'@/utils/github'; import * as abc from "@/utils/github" diff --git a/packages/wxt/src/core/utils/building/build-entrypoints.ts b/packages/wxt/src/core/utils/building/build-entrypoints.ts index 6abe4c72..181a0a0c 100644 --- a/packages/wxt/src/core/utils/building/build-entrypoints.ts +++ b/packages/wxt/src/core/utils/building/build-entrypoints.ts @@ -1,4 +1,9 @@ -import { BuildOutput, BuildStepOutput, EntrypointGroup } from '~/types'; +import { + BuildOutput, + BuildStepOutput, + EntrypointGroup, + ResolvedPublicFile, +} from '~/types'; import { getPublicFiles } from '~/core/utils/fs'; import fs from 'fs-extra'; import { dirname, resolve } from 'path'; @@ -32,19 +37,22 @@ export async function buildEntrypoints( } async function copyPublicDirectory(): Promise { - const files = await getPublicFiles(); + const files = (await getPublicFiles()).map((file) => ({ + absoluteSrc: resolve(wxt.config.publicDir, file), + relativeDest: file, + })); + await wxt.hooks.callHook('build:publicAssets', wxt, files); if (files.length === 0) return []; const publicAssets: BuildOutput['publicAssets'] = []; - for (const file of files) { - const srcPath = resolve(wxt.config.publicDir, file); - const outPath = resolve(wxt.config.outDir, file); + for (const { absoluteSrc, relativeDest } of files) { + const absoluteDest = resolve(wxt.config.outDir, relativeDest); - await fs.ensureDir(dirname(outPath)); - await fs.copyFile(srcPath, outPath); + await fs.ensureDir(dirname(absoluteDest)); + await fs.copyFile(absoluteSrc, absoluteDest); publicAssets.push({ type: 'asset', - fileName: file, + fileName: relativeDest, }); } diff --git a/packages/wxt/src/core/utils/building/resolve-config.ts b/packages/wxt/src/core/utils/building/resolve-config.ts index c013e549..88ff23eb 100644 --- a/packages/wxt/src/core/utils/building/resolve-config.ts +++ b/packages/wxt/src/core/utils/building/resolve-config.ts @@ -10,6 +10,7 @@ import { WxtResolvedUnimportOptions, Logger, WxtCommand, + WxtModule, } from '~/types'; import path from 'node:path'; import { createFsCache } from '~/core/utils/cache'; @@ -19,6 +20,7 @@ import { NullablyRequired } from '../types'; import { isModuleInstalled } from '../package'; import fs from 'fs-extra'; import { normalizePath } from '../paths'; +import glob from 'fast-glob'; /** * Given an inline config, discover the config file if necessary, merge the results, resolve any @@ -76,6 +78,7 @@ export async function resolveConfig( srcDir, mergedConfig.entrypointsDir ?? 'entrypoints', ); + const modulesDir = path.resolve(srcDir, mergedConfig.modulesDir ?? 'modules'); if (await isDirMissing(entrypointsDir)) { logMissingDir(logger, 'Entrypoints', entrypointsDir); } @@ -123,11 +126,22 @@ export async function resolveConfig( }; } + const modules = await resolveWxtModules(modulesDir, mergedConfig.modules); + const moduleOptions = modules.reduce>((map, module) => { + if (module.configKey) { + map[module.configKey] = + // @ts-expect-error + mergedConfig[module.configKey]; + } + return map; + }, {}); + return { browser, command, debug, entrypointsDir, + modulesDir, filterEntrypoints, env, fsCache: createFsCache(wxtDir), @@ -160,6 +174,9 @@ export async function resolveConfig( }, hooks: mergedConfig.hooks ?? {}, vite: mergedConfig.vite ?? (() => ({})), + modules, + plugins: [], + ...moduleOptions, }; } @@ -359,3 +376,47 @@ export async function mergeBuilderConfig( throw Error('Builder not found. Make sure vite is installed.'); } + +export async function resolveWxtModules( + modulesDir: string, + modules: string[] = [], +): Promise[]> { + // Resolve NPM packages + const npmModules = await Promise.all( + modules.map(async (moduleId) => { + const mod = await import(/* @vite-ignore */ moduleId); + if (mod.default == null) { + throw Error('Module missing default export: ' + moduleId); + } + return mod.default; + }), + ); + + // Resolve local file paths + const localModulePaths = await glob(['*.[tj]s', '*/index.[tj]s'], { + cwd: modulesDir, + onlyFiles: true, + }).catch(() => []); + const localModules = await Promise.all( + localModulePaths.map(async (file) => { + const { config } = await loadConfig>({ + configFile: path.resolve(modulesDir, file), + globalRc: false, + rcFile: false, + packageJson: false, + envName: false, + dotenv: false, + }); + if (config == null) + throw Error( + `No config found for ${file}. Did you forget to add a default export?`, + ); + // Add name based on filename + config.name ??= file; + return config; + }), + ); + + // Execute modules + return [...npmModules, ...localModules]; +} diff --git a/packages/wxt/src/core/utils/testing/fake-objects.ts b/packages/wxt/src/core/utils/testing/fake-objects.ts index 470325af..08c13385 100644 --- a/packages/wxt/src/core/utils/testing/fake-objects.ts +++ b/packages/wxt/src/core/utils/testing/fake-objects.ts @@ -238,6 +238,8 @@ export const fakeResolvedConfig = fakeObjectCreator(() => { browser, command, entrypointsDir: fakeDir(), + modulesDir: fakeDir(), + modules: [], env: { browser, command, manifestVersion, mode }, fsCache: mock(), imports: { @@ -302,6 +304,7 @@ export const fakeResolvedConfig = fakeObjectCreator(() => { }, hooks: {}, vite: () => ({}), + plugins: [], }; }); diff --git a/packages/wxt/src/core/wxt.ts b/packages/wxt/src/core/wxt.ts index c11b0fd8..dbfd1df4 100644 --- a/packages/wxt/src/core/wxt.ts +++ b/packages/wxt/src/core/wxt.ts @@ -45,6 +45,20 @@ export async function registerWxt( server, }; + // Initialize modules + for (const module of config.modules) { + if (module.hooks) wxt.hooks.addHooks(module.hooks); + if (wxt.config.imports !== false && module.imports) { + wxt.config.imports.imports ??= []; + wxt.config.imports.imports.push(...module.imports); + } + await module.setup?.( + wxt, + // @ts-expect-error: Untyped configKey field + module.configKey ? config[module.configKey] : undefined, + ); + } + // Initialize hooks wxt.hooks.addHooks(config.hooks); await wxt.hooks.callHook('ready', wxt); diff --git a/packages/wxt/src/modules.ts b/packages/wxt/src/modules.ts new file mode 100644 index 00000000..163a9b87 --- /dev/null +++ b/packages/wxt/src/modules.ts @@ -0,0 +1,130 @@ +/** + * Utilities for creating reusable, build-time modules for WXT. + * + * @module wxt/modules + */ +import type { + Entrypoint, + Wxt, + WxtModule, + WxtModuleOptions, + WxtModuleSetup, +} from './types'; +import * as vite from 'vite'; +import glob from 'fast-glob'; +import { resolve } from 'node:path'; + +export function defineWxtModule( + module: WxtModule | WxtModuleSetup, +): WxtModule { + if (typeof module === 'function') return { setup: module }; + return module; +} + +/** + * Adds a TS/JS file as an entrypoint to the project. This file will be bundled + * along with the other entrypoints. + + * If you're publishing the module to NPM, you should probably pre-build the + * entrypoint and use `addPublicAssets` instead to copy pre-bundled assets into + * the output directory. This will speed up project builds since it just has to + * copy some files instead of bundling them. + * + * @param wxt The wxt instance provided by the module's setup function. + * @param entrypoint The entrypoint to be bundled along with the extension. + * + * @example + * export default defineWxtModule((wxt, options) => { + * addEntrypoint(wxt, { + * type: "unlisted-page", + * name: "changelog", + * inputPath: "wxt-module-changelog/index.html" + * outputDir: wxt.config.outputDir, + * options: {}, + * }); + * }); + */ +export function addEntrypoint(wxt: Wxt, entrypoint: Entrypoint): void { + wxt.hooks.hook('entrypoints:resolved', (wxt, entrypoints) => { + entrypoints.push(entrypoint); + }); +} + +/** + * Copy files inside a directory (as if it were the public directory) into the + * extension's output directory. The directory itself is not copied, just the + * files inside it. If a filename matches an existing one, it is ignored. + * + * @param wxt The wxt instance provided by the module's setup function. + * @param dir The directory to copy. + * + * @example + * export default defineWxtModule((wxt, options) => { + * addPublicAssets(wxt, "./dist/prebundled"); + * }); + */ +export function addPublicAssets(wxt: Wxt, dir: string): void { + wxt.hooks.hook('build:publicAssets', async (wxt, files) => { + const moreFiles = await glob('**/*', { cwd: dir }); + if (moreFiles.length === 0) { + wxt.logger.warn('No files to copy in', dir); + return; + } + moreFiles.forEach((file) => { + files.unshift({ absoluteSrc: resolve(dir, file), relativeDest: file }); + }); + }); +} + +/** + * Merge additional vite config for one or more entrypoint "groups" that make + * up individual builds. Config in the project's `wxt.config.ts` file takes + * precedence over any config added by this function. + * + * @param wxt The wxt instance provided by the module's setup function. + * @param viteConfig A function that returns the vite config the module is + adding. Same format as `vite` in `wxt.config.ts`. + * + * @example + * export default defineWxtModule((wxt, options) => { + * addViteConfig(wxt, () => ({ + * build: { + * sourceMaps: true, + * }, + * }); + * }); + */ +export function addViteConfig( + wxt: Wxt, + viteConfig: (env: vite.ConfigEnv) => vite.UserConfig | undefined, +): void { + wxt.hooks.hook('ready', (wxt) => { + const userVite = wxt.config.vite; + wxt.config.vite = (env) => + vite.mergeConfig( + // Use config added by module as base + viteConfig(env) ?? {}, + // Overwrite module config with user config + userVite(env), + ); + }); +} + +/** + * Add a runtime plugin to the project. In each entrypoint, before executing + * the `main` function, plugins are executed. + * + * @param wxt The wxt instance provided by the module's setup function. + * @param plugin An import from an NPM module, or an absolute file path to the + * file to load at runtime. + * + * @example + * export default defineWxtModule((wxt) => { + * addWxtPlugin(wxt, "wxt-module-analytics/client-plugin"); + * }); + */ +export function addWxtPlugin(wxt: Wxt, plugin: string) { + wxt.hooks.hook('ready', (wxt) => { + wxt.config.plugins.push(plugin); + }); +} diff --git a/packages/wxt/src/sandbox/define-wxt-plugin.ts b/packages/wxt/src/sandbox/define-wxt-plugin.ts new file mode 100644 index 00000000..20745a94 --- /dev/null +++ b/packages/wxt/src/sandbox/define-wxt-plugin.ts @@ -0,0 +1,5 @@ +import type { WxtPlugin } from '~/types'; + +export function defineWxtPlugin(plugin: WxtPlugin): WxtPlugin { + return plugin; +} diff --git a/packages/wxt/src/sandbox/index.ts b/packages/wxt/src/sandbox/index.ts index 91b43851..3ba8c65d 100644 --- a/packages/wxt/src/sandbox/index.ts +++ b/packages/wxt/src/sandbox/index.ts @@ -6,4 +6,5 @@ export * from './define-unlisted-script'; export * from './define-background'; export * from './define-content-script'; +export * from './define-wxt-plugin'; export * from '@webext-core/match-patterns'; diff --git a/packages/wxt/src/types/index.ts b/packages/wxt/src/types/index.ts index dfc0565f..f6b952f1 100644 --- a/packages/wxt/src/types/index.ts +++ b/packages/wxt/src/types/index.ts @@ -1,6 +1,6 @@ import type * as vite from 'vite'; import type { Manifest, Scripting } from '~/browser'; -import { UnimportOptions } from 'unimport'; +import { UnimportOptions, Import } from 'unimport'; import { LogLevel } from 'consola'; import { ContentScriptContext } from '../client/content-scripts/content-script-context'; import type { PluginVisualizerOptions } from '@aklinker1/rollup-plugin-visualizer'; @@ -37,6 +37,10 @@ export interface InlineConfig { * @default "${config.srcDir}/entrypoints" */ entrypointsDir?: string; + /** + * @default "${config.srcDir}/modules" + */ + modulesDir?: string; /** * A list of entrypoint names (`"popup"`, `"options"`, etc.) to build. Will speed up the build if * your extension has lots of entrypoints, and you don't need to build all of them to develop a @@ -350,6 +354,12 @@ export interface InlineConfig { * Project hooks for running logic during the build process. */ hooks?: NestedHooks; + /** + * List of WXT module names to include. Can be the full package name + * ("wxt-module-analytics"), or just the suffix ("analytics" would resolve to + * "wxt-module-analytics"). + */ + modules?: string[]; } // TODO: Extract to @wxt/vite-builder and use module augmentation to include the vite field @@ -681,7 +691,7 @@ export interface BaseEntrypoint { */ inputPath: string; /** - * Absolute path to the entrypoint's output directory. Can be the`InternalConfg.outDir` or a + * Absolute path to the entrypoint's output directory. Can be `wxt.config.outDir` or a * subdirectory of it. */ outputDir: string; @@ -1055,6 +1065,13 @@ export interface WxtHooks { * @param entrypoints The list of groups to build in each build step */ 'entrypoints:grouped': (wxt: Wxt, groups: EntrypointGroup[]) => HookResult; + /** + * Called when public assets are found. You can modify the `files` list by + * reference to add or remove public files. + * @param wxt The configured WXT object + * @param entrypoints The list of files that will be copied into the output directory + */ + 'build:publicAssets': (wxt: Wxt, files: ResolvedPublicFile[]) => HookResult; } export interface Wxt { @@ -1086,15 +1103,31 @@ export interface ResolvedConfig { root: string; srcDir: string; publicDir: string; + /** + * Absolute path pointing to `.wxt` directory in project root. + * @example + * "/path/to/project/.wxt" + */ wxtDir: string; typesDir: string; entrypointsDir: string; + modulesDir: string; filterEntrypoints?: Set; + /** + * Absolute path to the `.output` directory + * @example + * "/path/to/project/.output" + */ outBaseDir: string; + /** + * Absolute path to the target output directory. + * @example + * "/path/to/project/.output/chrome-mv3" + */ outDir: string; debug: boolean; /** - * Directory pointing to `node_modules/wxt`, wherever WXT is installed. + * Absolute path pointing to the `node_modules/wxt` directory, wherever WXT is installed. */ wxtModuleDir: string; mode: string; @@ -1152,6 +1185,15 @@ export interface ResolvedConfig { reloadCommand: string | false; }; hooks: NestedHooks; + modules: WxtModule[]; + /** + * An array of string to import plugins from. These paths should be + * resolvable by vite, and they should `export default defineWxtPlugin(...)`. + * + * @example + * ["@wxt-dev/module-vue/plugin", "wxt-module-google-analytics/plugin"] + */ + plugins: string[]; } export interface FsCache { @@ -1253,3 +1295,49 @@ export interface Dependency { name: string; version: string; } + +export type WxtModuleOptions = Record; + +export type WxtModuleSetup = ( + wxt: Wxt, + moduleOptions: TOptions, +) => void | Promise; + +export interface WxtModule { + name?: string; + /** + * Key for users to pass options into your module from their `wxt.config.ts` file. + */ + configKey?: string; + /** + * Provide a list of imports to add to auto-imports. + */ + imports?: Import[]; + /** + * Alternative to adding hooks in setup function with `wxt.hooks`. Hooks are + * added before the `setup` function is called. + */ + hooks?: WxtHooks; + /** + * A custom function that can be used to setup hooks and call module-specific + * APIs. + */ + setup?: WxtModuleSetup; +} + +export interface ResolvedPublicFile { + /** + * The absolute path to the file that will be copied to the output directory. + * @example + * "/path/to/any/file.css" + */ + absoluteSrc: string; + /** + * The relative path in the output directory to copy the file to. + * @example + * "content-scripts/base-styles.css" + */ + relativeDest: string; +} + +export type WxtPlugin = () => void; diff --git a/packages/wxt/src/virtual/background-entrypoint.ts b/packages/wxt/src/virtual/background-entrypoint.ts index 52136aed..029dadbf 100644 --- a/packages/wxt/src/virtual/background-entrypoint.ts +++ b/packages/wxt/src/virtual/background-entrypoint.ts @@ -1,4 +1,5 @@ import definition from 'virtual:user-background-entrypoint'; +import { initPlugins } from 'virtual:wxt-plugins'; import { setupWebSocket } from './utils/setup-web-socket'; import { logger } from '../sandbox/utils/logger'; import { browser } from 'wxt/browser'; @@ -37,6 +38,7 @@ if (import.meta.env.COMMAND === 'serve') { let result; try { + initPlugins(); result = definition.main(); // @ts-expect-error: res shouldn't be a promise, but we're checking it anyways if (result instanceof Promise) { diff --git a/packages/wxt/src/virtual/content-script-isolated-world-entrypoint.ts b/packages/wxt/src/virtual/content-script-isolated-world-entrypoint.ts index 7363e07f..a80399d3 100644 --- a/packages/wxt/src/virtual/content-script-isolated-world-entrypoint.ts +++ b/packages/wxt/src/virtual/content-script-isolated-world-entrypoint.ts @@ -1,9 +1,11 @@ import definition from 'virtual:user-content-script-isolated-world-entrypoint'; import { logger } from '../sandbox/utils/logger'; import { ContentScriptContext } from 'wxt/client'; +import { initPlugins } from 'virtual:wxt-plugins'; const result = (async () => { try { + initPlugins(); const { main, ...options } = definition; const ctx = new ContentScriptContext(import.meta.env.ENTRYPOINT, options); diff --git a/packages/wxt/src/virtual/content-script-main-world-entrypoint.ts b/packages/wxt/src/virtual/content-script-main-world-entrypoint.ts index 95e78ca0..8d34d10e 100644 --- a/packages/wxt/src/virtual/content-script-main-world-entrypoint.ts +++ b/packages/wxt/src/virtual/content-script-main-world-entrypoint.ts @@ -1,8 +1,10 @@ import definition from 'virtual:user-content-script-main-world-entrypoint'; import { logger } from '../sandbox/utils/logger'; +import { initPlugins } from 'virtual:wxt-plugins'; const result = (async () => { try { + initPlugins(); const { main } = definition; return await main(); } catch (err) { diff --git a/packages/wxt/src/virtual/unlisted-script-entrypoint.ts b/packages/wxt/src/virtual/unlisted-script-entrypoint.ts index e9006408..a2a65bbb 100644 --- a/packages/wxt/src/virtual/unlisted-script-entrypoint.ts +++ b/packages/wxt/src/virtual/unlisted-script-entrypoint.ts @@ -1,8 +1,10 @@ import definition from 'virtual:user-unlisted-script-entrypoint'; import { logger } from '../sandbox/utils/logger'; +import { initPlugins } from 'virtual:wxt-plugins'; const result = (async () => { try { + initPlugins(); return await definition.main(); } catch (err) { logger.error( diff --git a/packages/wxt/src/virtual/virtual-module-globals.d.ts b/packages/wxt/src/virtual/virtual-module-globals.d.ts index 41f9fb30..ba96b54f 100644 --- a/packages/wxt/src/virtual/virtual-module-globals.d.ts +++ b/packages/wxt/src/virtual/virtual-module-globals.d.ts @@ -37,3 +37,7 @@ declare module 'wxt/client' { declare module 'wxt/testing' { export const fakeBrowser: import('webextension-polyfill').Browser; } + +declare module 'virtual:wxt-plugins' { + export function initPlugins(): void; +} diff --git a/packages/wxt/typedoc.json b/packages/wxt/typedoc.json index c1cfe796..b5472db1 100644 --- a/packages/wxt/typedoc.json +++ b/packages/wxt/typedoc.json @@ -5,6 +5,7 @@ "src/sandbox/index.ts", "src/browser.ts", "src/index.ts", - "src/storage.ts" + "src/storage.ts", + "src/modules.ts" ] }