feat: Module system (#672)
This commit is contained in:
@@ -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"
|
||||
```
|
||||
|
||||
@@ -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!
|
||||
:::
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"src/sandbox/index.ts",
|
||||
"src/browser.ts",
|
||||
"src/index.ts",
|
||||
"src/storage.ts"
|
||||
"src/storage.ts",
|
||||
"src/modules.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user