feat: Resolve entrypoints based on filesystem

This commit is contained in:
Aaron Klinker
2023-06-21 16:04:01 -05:00
parent 5b07c95ec7
commit a63f0618d5
14 changed files with 1506 additions and 51 deletions
+10
View File
@@ -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": {
+654 -16
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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
View File
@@ -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;
}
+5 -1
View File
@@ -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;
}
+413
View File
@@ -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)',
() => {},
);
});
+3
View File
@@ -0,0 +1,3 @@
import { describe } from 'vitest';
describe.todo('importTsFile');
+2 -2
View File
@@ -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;
}
+241
View File
@@ -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'
>;
+18 -27
View File
@@ -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;
}
+18
View File
@@ -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;
}
}
+15
View File
@@ -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',
};
}
+2
View File
@@ -0,0 +1,2 @@
export * from './download';
export * from './unimport';
+42
View File
@@ -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);
}