feat: Resolve entrypoints based on filesystem
This commit is contained in:
@@ -40,18 +40,27 @@
|
||||
"build:client": "tsup src/client/index.ts -d dist/client --sourcemap --dts --format esm,cjs",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --write .",
|
||||
"compile": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"prepare": "simple-git-hooks"
|
||||
},
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"consola": "^3.1.0",
|
||||
"fast-glob": "^3.2.12",
|
||||
"fs-extra": "^11.1.1",
|
||||
"jiti": "^1.18.2",
|
||||
"json5": "^2.2.3",
|
||||
"linkedom": "^0.14.26",
|
||||
"picomatch": "^2.3.1",
|
||||
"unimport": "^3.0.8",
|
||||
"vite": "^4.3.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/picomatch": "^2.3.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^2.8.8",
|
||||
"pretty-quick": "^3.1.3",
|
||||
@@ -59,6 +68,7 @@
|
||||
"tsup": "^7.0.0",
|
||||
"tsx": "^3.12.7",
|
||||
"typescript": "^5.1.3",
|
||||
"vitest": "^0.32.2",
|
||||
"webextension-polyfill": "^0.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
Generated
+654
-16
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { BuildOutput, ExviteDevServer, InlineConfig } from './types';
|
||||
import * as vite from 'vite';
|
||||
import { getInternalConfig } from './utils/getInternalConfig';
|
||||
import { findEntrypoints } from './utils/findEntrypoints';
|
||||
|
||||
export * from './types/external';
|
||||
export * from './utils/defineConfig';
|
||||
@@ -10,6 +11,8 @@ export * from './utils/defineConfig';
|
||||
*/
|
||||
export async function build(config: InlineConfig): Promise<BuildOutput> {
|
||||
const internalConfig = await getInternalConfig(config, 'build');
|
||||
const entrypoints = await findEntrypoints(internalConfig);
|
||||
console.log(entrypoints);
|
||||
throw Error('Not implemented');
|
||||
}
|
||||
|
||||
@@ -17,6 +20,7 @@ export async function createServer(
|
||||
config: InlineConfig,
|
||||
): Promise<ExviteDevServer> {
|
||||
const internalConfig = await getInternalConfig(config, 'serve');
|
||||
const entrypoints = await findEntrypoints(internalConfig);
|
||||
const server = await vite.createServer(internalConfig.vite);
|
||||
throw Error('Not implemented');
|
||||
}
|
||||
|
||||
+79
-5
@@ -4,6 +4,7 @@ import { UnimportPluginOptions } from 'unimport/unplugin';
|
||||
export interface InlineConfig {
|
||||
root?: string;
|
||||
srcDir?: string;
|
||||
entrypointsDir?: string;
|
||||
configFile?: string | false;
|
||||
storeIds?: {
|
||||
chrome?: string;
|
||||
@@ -13,23 +14,25 @@ export interface InlineConfig {
|
||||
mode?: string;
|
||||
imports?: Partial<UnimportPluginOptions>;
|
||||
browser?: TargetBrowser;
|
||||
manifestVersion?: TargetManifestVersion;
|
||||
logger?: Logger;
|
||||
vite?: Omit<vite.InlineConfig, 'root' | 'configFile' | 'mode'>;
|
||||
}
|
||||
|
||||
export interface ExviteInlineViteConfig
|
||||
extends Omit<vite.InlineConfig, 'root' | 'configFile' | 'mode' | 'build'> {
|
||||
build?: Omit<vite.BuildOptions, 'outDir'>;
|
||||
}
|
||||
|
||||
export interface BuildOutput {}
|
||||
|
||||
export interface ExviteDevServer {}
|
||||
|
||||
export type TargetBrowser = 'chromium' | 'firefox';
|
||||
export type TargetManifestVersion = 2 | 3;
|
||||
|
||||
export type UserConfig = Omit<InlineConfig, 'configFile'>;
|
||||
|
||||
export type UserConfigExport =
|
||||
| UserConfig
|
||||
| ((info: { mode: string }) => UserConfig)
|
||||
| ((info: { mode: string }) => Promise<UserConfig>);
|
||||
|
||||
export interface Logger {
|
||||
debug(...args: any[]): void;
|
||||
log(...args: any[]): void;
|
||||
@@ -38,3 +41,74 @@ export interface Logger {
|
||||
error(...args: any[]): void;
|
||||
fatal(...args: any[]): void;
|
||||
}
|
||||
|
||||
export interface GenericEntrypoint {
|
||||
type:
|
||||
| 'sandbox'
|
||||
| 'bookmarks'
|
||||
| 'history'
|
||||
| 'newtab'
|
||||
| 'sidepanel'
|
||||
| 'devtools'
|
||||
| 'unlisted-page'
|
||||
| 'unlisted-page'
|
||||
| 'unlisted-script';
|
||||
inputPath: string;
|
||||
outputDir: string;
|
||||
}
|
||||
|
||||
export interface BackgroundEntrypoint {
|
||||
type: 'background';
|
||||
options: {};
|
||||
inputPath: string;
|
||||
outputDir: string;
|
||||
}
|
||||
|
||||
export interface ContentScriptEntrypoint {
|
||||
type: 'content-script';
|
||||
options: Omit<ContentScriptDefinition, 'main'>;
|
||||
inputPath: string;
|
||||
outputDir: string;
|
||||
}
|
||||
|
||||
export interface PopupEntrypoint {
|
||||
type: 'popup';
|
||||
options: {
|
||||
defaultIcon?: Record<string, string>;
|
||||
defaultTitle?: string;
|
||||
};
|
||||
inputPath: string;
|
||||
outputDir: string;
|
||||
}
|
||||
|
||||
export interface OptionsEntrypoint {
|
||||
type: 'options';
|
||||
options: {
|
||||
openInTab?: boolean;
|
||||
};
|
||||
inputPath: string;
|
||||
outputDir: string;
|
||||
}
|
||||
|
||||
export type Entrypoint =
|
||||
| GenericEntrypoint
|
||||
| BackgroundEntrypoint
|
||||
| ContentScriptEntrypoint
|
||||
| PopupEntrypoint
|
||||
| OptionsEntrypoint;
|
||||
|
||||
export type OnContentScriptStopped = (cb: () => void) => void;
|
||||
|
||||
export interface ContentScriptDefinition {
|
||||
matches: string[];
|
||||
runAt?: 'document_start' | 'document_end' | 'document_idle';
|
||||
matchAboutBlank?: boolean;
|
||||
matchOriginAsFallback?: boolean;
|
||||
world?: 'ISOLATED' | 'MAIN';
|
||||
main(onStopped: OnContentScriptStopped): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface BackgroundScriptDefintition {
|
||||
type?: 'module';
|
||||
main(): void;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import * as vite from 'vite';
|
||||
import { Logger, TargetBrowser } from './external';
|
||||
import { Logger, TargetBrowser, TargetManifestVersion } from './external';
|
||||
|
||||
export interface InternalConfig {
|
||||
srcDir: string;
|
||||
entrypointsDir: string;
|
||||
outDir: string;
|
||||
storeIds: {
|
||||
chrome?: string;
|
||||
firefox?: string;
|
||||
edge?: string;
|
||||
};
|
||||
mode: string;
|
||||
command: 'build' | 'serve';
|
||||
browser: TargetBrowser;
|
||||
manifestVersion: TargetManifestVersion;
|
||||
logger: Logger;
|
||||
vite?: vite.InlineConfig;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
BackgroundEntrypoint,
|
||||
ContentScriptEntrypoint,
|
||||
GenericEntrypoint,
|
||||
OptionsEntrypoint,
|
||||
PopupEntrypoint,
|
||||
} from '../../types';
|
||||
import { resolve } from 'path';
|
||||
import { FindEntrypointsConfig, findEntrypoints } from '../findEntrypoints';
|
||||
import fs from 'fs-extra';
|
||||
import { importTsFile } from '../importTsFile';
|
||||
import glob from 'fast-glob';
|
||||
|
||||
vi.mock('../importTsFile');
|
||||
const importTsFileMock = vi.mocked(importTsFile);
|
||||
|
||||
vi.mock('fast-glob');
|
||||
const globMock = vi.mocked(glob);
|
||||
|
||||
vi.mock('fs-extra');
|
||||
const readFileMock = vi.mocked(
|
||||
fs.readFile as (path: string) => Promise<string>,
|
||||
);
|
||||
|
||||
describe('findEntrypoints', () => {
|
||||
const config: FindEntrypointsConfig = {
|
||||
entrypointsDir: resolve('/src/entrypoints'),
|
||||
outDir: resolve('.output'),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
fatal: (...args) => {
|
||||
throw Error('logger.fatal called with: ' + JSON.stringify(args));
|
||||
},
|
||||
info: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
command: 'build',
|
||||
mode: 'production',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each<[string, string, PopupEntrypoint]>([
|
||||
[
|
||||
'popup.html',
|
||||
`
|
||||
<html>
|
||||
<head>
|
||||
<meta name="manifest.default_icon" content="{ '16': '/icon/16.png' }" />
|
||||
<title>Default Title</title>
|
||||
</head>
|
||||
</html>
|
||||
`,
|
||||
{
|
||||
type: 'popup',
|
||||
inputPath: resolve(config.entrypointsDir, 'popup.html'),
|
||||
outputDir: resolve(config.outDir, 'popup'),
|
||||
options: {
|
||||
defaultIcon: { '16': '/icon/16.png' },
|
||||
defaultTitle: 'Default Title',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'popup/index.html',
|
||||
`
|
||||
<html>
|
||||
<head>
|
||||
<title>Title</title>
|
||||
</head>
|
||||
</html>
|
||||
`,
|
||||
{
|
||||
type: 'popup',
|
||||
inputPath: resolve(config.entrypointsDir, 'popup/index.html'),
|
||||
outputDir: resolve(config.outDir, 'popup'),
|
||||
options: {
|
||||
defaultTitle: 'Title',
|
||||
},
|
||||
},
|
||||
],
|
||||
])(
|
||||
'should find and load popup entrypoint config from %s',
|
||||
async (path, content, expected) => {
|
||||
globMock.mockResolvedValueOnce([path]);
|
||||
readFileMock.mockResolvedValueOnce(content);
|
||||
|
||||
const entrypoints = await findEntrypoints(config);
|
||||
|
||||
expect(entrypoints).toHaveLength(1);
|
||||
expect(entrypoints[0]).toEqual(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each<[string, string, OptionsEntrypoint]>([
|
||||
[
|
||||
'options.html',
|
||||
`
|
||||
<html>
|
||||
<head>
|
||||
<title>Default Title</title>
|
||||
</head>
|
||||
</html>
|
||||
`,
|
||||
{
|
||||
type: 'options',
|
||||
inputPath: resolve(config.entrypointsDir, 'options.html'),
|
||||
outputDir: resolve(config.outDir, 'options'),
|
||||
options: {},
|
||||
},
|
||||
],
|
||||
[
|
||||
'options/index.html',
|
||||
`
|
||||
<html>
|
||||
<head>
|
||||
<meta name="manifest.open_in_tab" content="true" />
|
||||
<title>Title</title>
|
||||
</head>
|
||||
</html>
|
||||
`,
|
||||
{
|
||||
type: 'options',
|
||||
inputPath: resolve(config.entrypointsDir, 'options/index.html'),
|
||||
outputDir: resolve(config.outDir, 'options'),
|
||||
options: {
|
||||
openInTab: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
])(
|
||||
'should find and load options entrypoint config from %s',
|
||||
async (path, content, expected) => {
|
||||
globMock.mockResolvedValueOnce([path]);
|
||||
readFileMock.mockResolvedValueOnce(content);
|
||||
|
||||
const entrypoints = await findEntrypoints(config);
|
||||
|
||||
expect(entrypoints).toHaveLength(1);
|
||||
expect(entrypoints[0]).toEqual(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each<[string, Omit<ContentScriptEntrypoint, 'options'>]>([
|
||||
[
|
||||
'overlay.content.ts',
|
||||
{
|
||||
type: 'content-script',
|
||||
inputPath: resolve(config.entrypointsDir, 'overlay.content.ts'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts/overlay'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'overlay.content/index.ts',
|
||||
{
|
||||
type: 'content-script',
|
||||
inputPath: resolve(config.entrypointsDir, 'overlay.content/index.ts'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts/overlay'),
|
||||
},
|
||||
],
|
||||
])(
|
||||
'should find and load content script entrypoint config from %s',
|
||||
async (path, expected) => {
|
||||
const options: ContentScriptEntrypoint['options'] = {
|
||||
matches: ['<all_urls>'],
|
||||
};
|
||||
globMock.mockResolvedValueOnce([path]);
|
||||
importTsFileMock.mockResolvedValue({
|
||||
defaultExport: options,
|
||||
dependencies: [],
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each<[string, Omit<BackgroundEntrypoint, 'options'>]>([
|
||||
[
|
||||
'background.ts',
|
||||
{
|
||||
type: 'background',
|
||||
inputPath: resolve(config.entrypointsDir, 'background.ts'),
|
||||
outputDir: resolve(config.outDir, 'background'),
|
||||
},
|
||||
],
|
||||
])(
|
||||
'should find and load content script entrypoint config from %s',
|
||||
async (path, expected) => {
|
||||
const options: ContentScriptEntrypoint['options'] = {
|
||||
matches: ['<all_urls>'],
|
||||
};
|
||||
globMock.mockResolvedValueOnce([path]);
|
||||
importTsFileMock.mockResolvedValue({
|
||||
defaultExport: options,
|
||||
dependencies: [],
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each<[string, GenericEntrypoint]>([
|
||||
// Sandbox
|
||||
[
|
||||
'sandbox.html',
|
||||
{
|
||||
type: 'sandbox',
|
||||
inputPath: resolve(config.entrypointsDir, 'sandbox.html'),
|
||||
outputDir: resolve(config.outDir, 'sandbox'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'sandbox/index.html',
|
||||
{
|
||||
type: 'sandbox',
|
||||
inputPath: resolve(config.entrypointsDir, 'sandbox/index.html'),
|
||||
outputDir: resolve(config.outDir, 'sandbox'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'named.sandbox.html',
|
||||
{
|
||||
type: 'sandbox',
|
||||
inputPath: resolve(config.entrypointsDir, 'named.sandbox.html'),
|
||||
outputDir: resolve(config.outDir, 'named.sandbox'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'named.sandbox/index.html',
|
||||
{
|
||||
type: 'sandbox',
|
||||
inputPath: resolve(config.entrypointsDir, 'named.sandbox/index.html'),
|
||||
outputDir: resolve(config.outDir, 'named.sandbox'),
|
||||
},
|
||||
],
|
||||
|
||||
// bookmarks
|
||||
[
|
||||
'bookmarks.html',
|
||||
{
|
||||
type: 'bookmarks',
|
||||
inputPath: resolve(config.entrypointsDir, 'bookmarks.html'),
|
||||
outputDir: resolve(config.outDir, 'bookmarks'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'bookmarks/index.html',
|
||||
{
|
||||
type: 'bookmarks',
|
||||
inputPath: resolve(config.entrypointsDir, 'bookmarks/index.html'),
|
||||
outputDir: resolve(config.outDir, 'bookmarks'),
|
||||
},
|
||||
],
|
||||
|
||||
// history
|
||||
[
|
||||
'history.html',
|
||||
{
|
||||
type: 'history',
|
||||
inputPath: resolve(config.entrypointsDir, 'history.html'),
|
||||
outputDir: resolve(config.outDir, 'history'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'history/index.html',
|
||||
{
|
||||
type: 'history',
|
||||
inputPath: resolve(config.entrypointsDir, 'history/index.html'),
|
||||
outputDir: resolve(config.outDir, 'history'),
|
||||
},
|
||||
],
|
||||
|
||||
// newtab
|
||||
[
|
||||
'newtab.html',
|
||||
{
|
||||
type: 'newtab',
|
||||
inputPath: resolve(config.entrypointsDir, 'newtab.html'),
|
||||
outputDir: resolve(config.outDir, 'newtab'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'newtab/index.html',
|
||||
{
|
||||
type: 'newtab',
|
||||
inputPath: resolve(config.entrypointsDir, 'newtab/index.html'),
|
||||
outputDir: resolve(config.outDir, 'newtab'),
|
||||
},
|
||||
],
|
||||
|
||||
// sidepanel
|
||||
[
|
||||
'sidepanel.html',
|
||||
{
|
||||
type: 'sidepanel',
|
||||
inputPath: resolve(config.entrypointsDir, 'sidepanel.html'),
|
||||
outputDir: resolve(config.outDir, 'sidepanel'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'sidepanel/index.html',
|
||||
{
|
||||
type: 'sidepanel',
|
||||
inputPath: resolve(config.entrypointsDir, 'sidepanel/index.html'),
|
||||
outputDir: resolve(config.outDir, 'sidepanel'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'named.sidepanel.html',
|
||||
{
|
||||
type: 'sidepanel',
|
||||
inputPath: resolve(config.entrypointsDir, 'named.sidepanel.html'),
|
||||
outputDir: resolve(config.outDir, 'named.sidepanel'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'named.sidepanel/index.html',
|
||||
{
|
||||
type: 'sidepanel',
|
||||
inputPath: resolve(config.entrypointsDir, 'named.sidepanel/index.html'),
|
||||
outputDir: resolve(config.outDir, 'named.sidepanel'),
|
||||
},
|
||||
],
|
||||
|
||||
// devtools
|
||||
[
|
||||
'devtools.html',
|
||||
{
|
||||
type: 'devtools',
|
||||
inputPath: resolve(config.entrypointsDir, 'devtools.html'),
|
||||
outputDir: resolve(config.outDir, 'devtools'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'devtools/index.html',
|
||||
{
|
||||
type: 'devtools',
|
||||
inputPath: resolve(config.entrypointsDir, 'devtools/index.html'),
|
||||
outputDir: resolve(config.outDir, 'devtools'),
|
||||
},
|
||||
],
|
||||
|
||||
// unlisted-page
|
||||
[
|
||||
'onboarding.html',
|
||||
{
|
||||
type: 'unlisted-page',
|
||||
inputPath: resolve(config.entrypointsDir, 'onboarding.html'),
|
||||
outputDir: resolve(config.outDir, 'onboarding'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'onboarding/index.html',
|
||||
{
|
||||
type: 'unlisted-page',
|
||||
inputPath: resolve(config.entrypointsDir, 'onboarding/index.html'),
|
||||
outputDir: resolve(config.outDir, 'onboarding'),
|
||||
},
|
||||
],
|
||||
|
||||
// unlisted-script
|
||||
[
|
||||
'injected.ts',
|
||||
{
|
||||
type: 'unlisted-script',
|
||||
inputPath: resolve(config.entrypointsDir, 'injected.ts'),
|
||||
outputDir: resolve(config.outDir, 'injected'),
|
||||
},
|
||||
],
|
||||
])('should find entrypoint for %s', async (path, expected) => {
|
||||
globMock.mockResolvedValueOnce([path]);
|
||||
|
||||
const entrypoints = await findEntrypoints(config);
|
||||
|
||||
expect(entrypoints).toHaveLength(1);
|
||||
expect(entrypoints[0]).toEqual(expected);
|
||||
});
|
||||
|
||||
it.todo(
|
||||
'should ignore CSS and JS files inside a HTML page directory',
|
||||
() => {},
|
||||
);
|
||||
|
||||
it.todo(
|
||||
'should warn when there is an unexpected file in the entrypoints directory',
|
||||
() => {},
|
||||
);
|
||||
|
||||
it.todo(
|
||||
'should ignore entrypoints starting with a . (preventing warnings like .DS_Store files)',
|
||||
() => {},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { describe } from 'vitest';
|
||||
|
||||
describe.todo('importTsFile');
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UserConfigExport } from '../types';
|
||||
import { UserConfig } from '../types';
|
||||
|
||||
export function defineConfig(config: UserConfigExport): UserConfigExport {
|
||||
export function defineConfig(config: UserConfig): UserConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { resolve } from 'path';
|
||||
import {
|
||||
BackgroundEntrypoint,
|
||||
BackgroundScriptDefintition,
|
||||
ContentScriptDefinition,
|
||||
ContentScriptEntrypoint,
|
||||
Entrypoint,
|
||||
InternalConfig,
|
||||
OptionsEntrypoint,
|
||||
PopupEntrypoint,
|
||||
} from '../types';
|
||||
import fs from 'fs-extra';
|
||||
import picomatch from 'picomatch';
|
||||
import { parseHTML } from 'linkedom';
|
||||
import JSON5 from 'json5';
|
||||
import { importTsFile } from './importTsFile';
|
||||
import glob from 'fast-glob';
|
||||
|
||||
/**
|
||||
* Return entrypoints and their configuration by looking through the
|
||||
*/
|
||||
export async function findEntrypoints(
|
||||
config: FindEntrypointsConfig,
|
||||
): Promise<Entrypoint[]> {
|
||||
const relativePaths = await glob('**/*', {
|
||||
cwd: config.entrypointsDir,
|
||||
});
|
||||
// Ensure consistent output
|
||||
relativePaths.sort();
|
||||
|
||||
const pathGlobs = Object.keys(PATH_GLOB_TO_TYPE_MAP);
|
||||
|
||||
const entrypoints: Entrypoint[] = [];
|
||||
await Promise.all(
|
||||
relativePaths.map(async (relativePath) => {
|
||||
const path = resolve(config.entrypointsDir, relativePath);
|
||||
const matchingGlob = pathGlobs.find((glob) =>
|
||||
picomatch.isMatch(relativePath, glob),
|
||||
);
|
||||
|
||||
if (matchingGlob == null) {
|
||||
return config.logger.warn(
|
||||
`${relativePath} does not match any known entrypoint. Known entrypoints:\n${JSON.stringify(
|
||||
PATH_GLOB_TO_TYPE_MAP,
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const type = PATH_GLOB_TO_TYPE_MAP[matchingGlob];
|
||||
if (type === 'ignored') return;
|
||||
|
||||
let entrypoint: Entrypoint;
|
||||
switch (type) {
|
||||
case 'popup':
|
||||
entrypoint = await getPopupEntrypoint(config, path);
|
||||
break;
|
||||
case 'options':
|
||||
entrypoint = await getOptionsEntrypoint(config, path);
|
||||
break;
|
||||
case 'background':
|
||||
entrypoint = await getBackgroundEntrypoint(config, path);
|
||||
break;
|
||||
case 'content-script':
|
||||
entrypoint = await getContentScriptEntrypoint(
|
||||
config,
|
||||
relativePath.split('.', 2)[0],
|
||||
path,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
const name = relativePath.split(/[\.\/]/, 2)[0];
|
||||
const outDir =
|
||||
name === type || type.startsWith('unlisted')
|
||||
? name
|
||||
: `${name}.${type}`;
|
||||
entrypoint = {
|
||||
type,
|
||||
inputPath: path,
|
||||
outputDir: resolve(config.outDir, outDir),
|
||||
};
|
||||
}
|
||||
|
||||
entrypoints.push(entrypoint);
|
||||
}),
|
||||
);
|
||||
return entrypoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param path Absolute path to the popup HTML file.
|
||||
* @param content String contents of the file at the path.
|
||||
*/
|
||||
async function getPopupEntrypoint(
|
||||
config: FindEntrypointsConfig,
|
||||
path: string,
|
||||
): Promise<PopupEntrypoint> {
|
||||
const options: PopupEntrypoint['options'] = {};
|
||||
|
||||
const content = await fs.readFile(path, 'utf-8');
|
||||
const { document } = parseHTML(content);
|
||||
const title = document.querySelector('title');
|
||||
if (title != null) options.defaultTitle = title.textContent ?? undefined;
|
||||
const defaultIconContent = document
|
||||
.querySelector("meta[name='manifest.default_icon']")
|
||||
?.getAttribute('content');
|
||||
if (defaultIconContent) {
|
||||
try {
|
||||
options.defaultIcon = JSON5.parse(defaultIconContent);
|
||||
} catch (err) {
|
||||
config.logger.fatal(
|
||||
`Failed to parse default_icon meta tag content as JSON5. content=${defaultIconContent}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'popup',
|
||||
options,
|
||||
inputPath: path,
|
||||
outputDir: resolve(config.outDir, 'popup'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param path Absolute path to the options HTML file.
|
||||
* @param content String contents of the file at the path.
|
||||
*/
|
||||
async function getOptionsEntrypoint(
|
||||
config: FindEntrypointsConfig,
|
||||
path: string,
|
||||
): Promise<OptionsEntrypoint> {
|
||||
const options: OptionsEntrypoint['options'] = {};
|
||||
|
||||
const content = await fs.readFile(path, 'utf-8');
|
||||
const { document } = parseHTML(content);
|
||||
const defaultIconContent = document
|
||||
.querySelector("meta[name='manifest.open_in_tab']")
|
||||
?.getAttribute('content');
|
||||
if (defaultIconContent) {
|
||||
options.openInTab = Boolean(defaultIconContent);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'options',
|
||||
options,
|
||||
inputPath: path,
|
||||
outputDir: resolve(config.outDir, 'options'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param path Absolute path to the background's TS file.
|
||||
*/
|
||||
async function getBackgroundEntrypoint(
|
||||
config: FindEntrypointsConfig,
|
||||
path: string,
|
||||
): Promise<BackgroundEntrypoint> {
|
||||
const { main: _, ...options } =
|
||||
await importTsFile<BackgroundScriptDefintition>(path);
|
||||
if (options == null) {
|
||||
throw Error('Background script does not have a default export');
|
||||
}
|
||||
return {
|
||||
type: 'background',
|
||||
inputPath: path,
|
||||
outputDir: resolve(config.outDir, 'background'),
|
||||
options: options,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param path Absolute path to the content script's TS file.
|
||||
*/
|
||||
async function getContentScriptEntrypoint(
|
||||
config: FindEntrypointsConfig,
|
||||
name: string,
|
||||
path: string,
|
||||
): Promise<ContentScriptEntrypoint> {
|
||||
const { main: _, ...options } = await importTsFile<ContentScriptDefinition>(
|
||||
path,
|
||||
);
|
||||
if (options == null) {
|
||||
throw Error(`Content script ${name} does not have a default export`);
|
||||
}
|
||||
return {
|
||||
type: 'content-script',
|
||||
inputPath: path,
|
||||
outputDir: resolve(config.outDir, 'content-scripts', name),
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type'] | 'ignored'> = {
|
||||
'sandbox.html': 'sandbox',
|
||||
'sandbox/index.html': 'sandbox',
|
||||
'*.sandbox.html': 'sandbox',
|
||||
'*.sandbox/index.html': 'sandbox',
|
||||
|
||||
'bookmarks.html': 'bookmarks',
|
||||
'bookmarks/index.html': 'bookmarks',
|
||||
|
||||
'history.html': 'history',
|
||||
'history/index.html': 'history',
|
||||
|
||||
'newtab.html': 'newtab',
|
||||
'newtab/index.html': 'newtab',
|
||||
|
||||
'sidepanel.html': 'sidepanel',
|
||||
'sidepanel/index.html': 'sidepanel',
|
||||
'*.sidepanel.html': 'sidepanel',
|
||||
'*.sidepanel/index.html': 'sidepanel',
|
||||
|
||||
'devtools.html': 'devtools',
|
||||
'devtools/index.html': 'devtools',
|
||||
|
||||
'background.ts': 'background',
|
||||
|
||||
'*.content.ts': 'content-script',
|
||||
'*.content/index.ts': 'content-script',
|
||||
|
||||
'popup.html': 'popup',
|
||||
'popup/index.html': 'popup',
|
||||
|
||||
'options.html': 'options',
|
||||
'options/index.html': 'options',
|
||||
|
||||
'*.html': 'unlisted-page',
|
||||
'*/index.html': 'unlisted-page',
|
||||
'*.ts': 'unlisted-script',
|
||||
|
||||
// Don't warn about any files in subdirectories, like CSS or JS entrypoints for HTML files
|
||||
'*/*': 'ignored',
|
||||
};
|
||||
|
||||
export type FindEntrypointsConfig = Pick<
|
||||
InternalConfig,
|
||||
'entrypointsDir' | 'outDir' | 'logger' | 'mode' | 'command'
|
||||
>;
|
||||
@@ -1,9 +1,9 @@
|
||||
import { InlineConfig, InternalConfig, UserConfig } from '../types';
|
||||
import path from 'node:path';
|
||||
import * as vite from 'vite';
|
||||
import Unimport, { UnimportPluginOptions } from 'unimport/unplugin';
|
||||
import { consola } from 'consola';
|
||||
import fs from 'fs-extra';
|
||||
import { importTsFile } from './importTsFile';
|
||||
import * as plugins from '../vite-plugins';
|
||||
|
||||
/**
|
||||
* Given an inline config, discover the config file if necessary, merge the results, resolve any
|
||||
@@ -17,14 +17,25 @@ export async function getInternalConfig(
|
||||
const root = config.root ? path.resolve(config.root) : process.cwd();
|
||||
const srcDir =
|
||||
config.srcDir == null ? root : path.resolve(root, config.srcDir);
|
||||
const entrypointsDir = path.resolve(
|
||||
srcDir,
|
||||
config.entrypointsDir ?? 'entrypoints',
|
||||
);
|
||||
const mode =
|
||||
config.mode ?? (command === 'build' ? 'production' : 'development');
|
||||
const browser = config.browser ?? 'chromium';
|
||||
const manifestVersion =
|
||||
config.manifestVersion ?? (browser === 'chromium' ? 3 : 2);
|
||||
|
||||
const baseConfig: InternalConfig = {
|
||||
srcDir,
|
||||
entrypointsDir,
|
||||
storeIds: config.storeIds ?? {},
|
||||
browser: config.browser ?? 'chromium',
|
||||
browser,
|
||||
manifestVersion,
|
||||
mode,
|
||||
command,
|
||||
outDir: '.output',
|
||||
logger: config.logger ?? consola,
|
||||
vite: config.vite,
|
||||
};
|
||||
@@ -34,11 +45,9 @@ export async function getInternalConfig(
|
||||
mode: config.mode,
|
||||
};
|
||||
if (config.configFile !== false) {
|
||||
const loadedConfig = await vite.loadConfigFromFile(
|
||||
{ command, mode },
|
||||
config.configFile ?? 'exvite.config.ts',
|
||||
userConfig = await importTsFile<UserConfig>(
|
||||
path.resolve(config.configFile ?? 'exvite.config.ts'),
|
||||
);
|
||||
userConfig = loadedConfig?.config as any;
|
||||
}
|
||||
|
||||
// Merge inline and user configs
|
||||
@@ -50,26 +59,8 @@ export async function getInternalConfig(
|
||||
merged.vite.configFile = false;
|
||||
merged.vite.plugins ??= [];
|
||||
|
||||
const defaultOptions: UnimportPluginOptions = {
|
||||
include: srcDir,
|
||||
exclude: [],
|
||||
addons: [],
|
||||
debugLog: () => {},
|
||||
dts: path.resolve(root, '.exvite/types/imports.d.ts'),
|
||||
imports: [{ name: '*', as: 'browser', from: 'webextension-polyfill' }],
|
||||
presets: [],
|
||||
virtualImports: [],
|
||||
warn: () => {},
|
||||
dirs: ['components', 'composables', 'hooks', 'utils'],
|
||||
};
|
||||
const unimportConfig = vite.mergeConfig(
|
||||
defaultOptions,
|
||||
userConfig.imports ?? {},
|
||||
) as UnimportPluginOptions;
|
||||
const unimport: typeof Unimport.vite =
|
||||
// @ts-expect-error: esm availabe within the default object?
|
||||
Unimport.vite ?? Unimport.default?.vite;
|
||||
merged.vite.plugins.push(unimport(unimportConfig));
|
||||
merged.vite.plugins.push(plugins.unimport(root, srcDir, userConfig.imports));
|
||||
merged.vite.plugins.push(plugins.download());
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import createJITI from 'jiti';
|
||||
|
||||
export async function importTsFile<T>(path: string): Promise<T> {
|
||||
const jiti = createJITI(__filename, {
|
||||
alias: {
|
||||
'webextension-polyfill': 'exvite',
|
||||
},
|
||||
cache: false,
|
||||
esmResolve: true,
|
||||
interopDefault: true,
|
||||
});
|
||||
try {
|
||||
return await jiti(path);
|
||||
} catch (err) {
|
||||
console.error(`Failed to import file: ${path}`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Plugin } from 'vite';
|
||||
|
||||
/**
|
||||
* Downloads any URL imports into virtual modules, like Google Analytics, so they are bundled with
|
||||
* the extension instead of depending on remote code.
|
||||
*
|
||||
* @example
|
||||
* import "https://google-tagmanager.com/gtag?id=XYZ";
|
||||
*/
|
||||
export function download(): Plugin {
|
||||
console.warn('Not implemented: download plugin');
|
||||
return {
|
||||
name: 'exvite:download',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './download';
|
||||
export * from './unimport';
|
||||
@@ -0,0 +1,42 @@
|
||||
import Unimport, { UnimportPluginOptions } from 'unimport/unplugin';
|
||||
import { UserConfig } from '../types';
|
||||
import path from 'node:path';
|
||||
import * as vite from 'vite';
|
||||
|
||||
/**
|
||||
* Apply defaults and returns an instance of the unimport plugin.
|
||||
*
|
||||
* @param root Absolute path to the vite root
|
||||
* @param srcDir Absolute path to the source directory
|
||||
* @param imports Custom import settings defined by the user
|
||||
*/
|
||||
export function unimport(
|
||||
root: string,
|
||||
srcDir: string,
|
||||
imports: UserConfig['imports'],
|
||||
) {
|
||||
const defaultOptions: UnimportPluginOptions = {
|
||||
include: srcDir,
|
||||
exclude: [],
|
||||
addons: [],
|
||||
debugLog: () => {},
|
||||
dts: path.resolve(root, '.exvite/types/imports.d.ts'),
|
||||
imports: [{ name: '*', as: 'browser', from: 'webextension-polyfill' }],
|
||||
presets: [
|
||||
// Scan for exported functions from the client package
|
||||
{ package: 'exvite/client' },
|
||||
],
|
||||
virtualImports: [],
|
||||
warn: () => {},
|
||||
dirs: ['components', 'composables', 'hooks', 'utils'],
|
||||
};
|
||||
const unimportConfig = vite.mergeConfig(
|
||||
defaultOptions,
|
||||
imports ?? {},
|
||||
) as UnimportPluginOptions;
|
||||
const unimport: typeof Unimport.vite =
|
||||
// @ts-expect-error: esm availabe within the default object?
|
||||
Unimport.vite ?? Unimport.default?.vite;
|
||||
|
||||
return unimport(unimportConfig);
|
||||
}
|
||||
Reference in New Issue
Block a user