feat: Build entrypoints and output manfiest.json

This commit is contained in:
Aaron Klinker
2023-06-22 16:49:16 -05:00
parent f09ffbb803
commit 1e7c738de4
33 changed files with 1049 additions and 94 deletions
+4
View File
@@ -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: ['*://*/*'],
+1
View File
@@ -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>
+10
View File
@@ -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>
+3
View File
@@ -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
View File
@@ -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",
+16 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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` &rarr; `popup`
* - `options/index.html` &rarr; `options`
* - `named.sandbox.html` &rarr; `named`
* - `named.sandbox/index.html` &rarr; `named`
* - `sandbox.html` &rarr; `sandbox`
* - `sandbox.index.html` &rarr; `sandbox`
* - `overlay.content.ts` &rarr; `overlay`
* - `overlay.content/index.ts` &rarr; `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
View File
@@ -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[];
+51
View File
@@ -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);
});
});
});
+67 -42
View File
@@ -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);
});
});
-3
View File
@@ -1,3 +0,0 @@
import { describe } from 'vitest';
describe.todo('importTsFile');
+93
View File
@@ -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;
}
+33
View File
@@ -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));
}
+13 -12
View File
@@ -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',
+14 -4
View File
@@ -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;
}
+48
View File
@@ -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';
+11 -1
View File
@@ -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;
}
}
+274
View File
@@ -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;
}
+51
View File
@@ -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,
};
+28
View File
@@ -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);
}
+19
View File
@@ -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.
}
}
+10
View File
@@ -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), '');
}
+3 -2
View File
@@ -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
View File
@@ -1,2 +1,3 @@
export * from './download';
export * from './unimport';
export * from './multipageMove';
+67
View File
@@ -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;
}
},
};
}
+6 -1
View File
@@ -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
+3
View File
@@ -10,6 +10,9 @@
/* Type Checking */
"strict": true,
/* Linting */
"noUnusedLocals": true,
/* Completeness */
"skipLibCheck": true
}