feat: Module system (#672)

This commit is contained in:
Aaron
2024-06-11 17:45:34 -05:00
committed by GitHub
parent eb3b9bc334
commit b3ddab7bcb
34 changed files with 1007 additions and 43 deletions
+1
View File
@@ -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'),
@@ -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 `'/<name>.html'`:
```ts
const url = browser.runtime.getURL('/<name>.html');
const url = browser.runtime.getURL('/<name>.html', '<html></html>');
console.log(url); // "chrome-extension://<id>/<name>.html"
```
+186
View File
@@ -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<AnalyticsModuleOptions>({
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!
:::
+19
View File
@@ -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<ExampleModuleOptions>({
configKey: 'example',
setup(wxt, options) {
wxt.logger.info('Example module with options:', options);
},
});
+5
View File
@@ -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',
},
});
+10 -10
View File
@@ -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', '<html></html>');
project.addFile('entrypoints/options.html', '<html></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', '<html></html>');
project.addFile('entrypoints/options.html', '<html></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', '<html></html>');
project.addFile('entrypoints/options.html', '<html></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', '<html></html>');
project.addFile('entrypoints/options.html', '<html></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', '<html></html>');
project.addFile('entrypoints/options.html', '<html></html>');
project.addFile(
'entrypoints/background.ts',
'export default defineBackground(() => {});',
@@ -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"
}
+5
View File
@@ -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,
+291
View File
@@ -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',
`
<html>
<body></body>
</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);
});
});
});
@@ -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', '<html></html>');
project.addFile('entrypoints/.hidden2.html', '<html></html>');
project.addFile('entrypoints/unlisted.html', '<html></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
----------------------------------------
"
<html><head> <script type="module" crossorigin src="/chunks/unlisted-DPbbfBKe.js"></script>
</head></html>"
`);
});
@@ -159,7 +165,7 @@ describe('Output Directory Structure', () => {
);
project.addFile('entrypoints/popup.html', '<html></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', '<html></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(
@@ -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', '<html></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', '<html></html>');
project.addFile('entrypoints/options.html', '<html></html>');
project.addFile('entrypoints/sandbox.html', '<html></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', '<html></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', '<html></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', '<html></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', '<html></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', '<html></html>');
project.setConfigFileConfig({
srcDir: 'src',
alias: {
+1 -1
View File
@@ -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', '<html></html>');
project.addFile(
'wxt.config.ts',
`import { defineConfig } from 'wxt';
+3 -1
View File
@@ -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 = {}) {
+10
View File
@@ -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": {
+5 -1
View File
@@ -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,
@@ -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);
});
});
});
@@ -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));
@@ -14,3 +14,4 @@ export * from './excludeBrowserPolyfill';
export * from './entrypointGroupGlobals';
export * from './defineImportMeta';
export * from './removeEntrypointMainFunction';
export * from './wxtPluginLoader';
@@ -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 `<head>`
*/
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();
},
},
};
}
@@ -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"
@@ -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<BuildOutput['publicAssets']> {
const files = await getPublicFiles();
const files = (await getPublicFiles()).map<ResolvedPublicFile>((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,
});
}
@@ -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<Record<string, any>>((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<WxtModule<any>[]> {
// 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<WxtModule<any>>({
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];
}
@@ -238,6 +238,8 @@ export const fakeResolvedConfig = fakeObjectCreator<ResolvedConfig>(() => {
browser,
command,
entrypointsDir: fakeDir(),
modulesDir: fakeDir(),
modules: [],
env: { browser, command, manifestVersion, mode },
fsCache: mock<FsCache>(),
imports: {
@@ -302,6 +304,7 @@ export const fakeResolvedConfig = fakeObjectCreator<ResolvedConfig>(() => {
},
hooks: {},
vite: () => ({}),
plugins: [],
};
});
+14
View File
@@ -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);
+130
View File
@@ -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<TOptions extends WxtModuleOptions>(
module: WxtModule<TOptions> | WxtModuleSetup<TOptions>,
): WxtModule<TOptions> {
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);
});
}
@@ -0,0 +1,5 @@
import type { WxtPlugin } from '~/types';
export function defineWxtPlugin(plugin: WxtPlugin): WxtPlugin {
return plugin;
}
+1
View File
@@ -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';
+91 -3
View File
@@ -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<WxtHooks>;
/**
* 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<string>;
/**
* 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<WxtHooks>;
modules: WxtModule<any>[];
/**
* 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<string, any>;
export type WxtModuleSetup<TOptions extends WxtModuleOptions> = (
wxt: Wxt,
moduleOptions: TOptions,
) => void | Promise<void>;
export interface WxtModule<TOptions extends WxtModuleOptions> {
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<TOptions>;
}
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;
@@ -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) {
@@ -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);
@@ -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) {
@@ -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(
+4
View File
@@ -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;
}
+2 -1
View File
@@ -5,6 +5,7 @@
"src/sandbox/index.ts",
"src/browser.ts",
"src/index.ts",
"src/storage.ts"
"src/storage.ts",
"src/modules.ts"
]
}