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