Files
wxt/src/core/build/findEntrypoints.ts
T
2023-06-25 21:11:11 -05:00

282 lines
7.5 KiB
TypeScript

import { relative, 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 '../utils/importTsFile';
import glob from 'fast-glob';
import { getEntrypointName } from '../utils/entrypoints';
/**
* 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 existingNames: Record<string, Entrypoint | undefined> = {};
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:
entrypoint = {
type,
name: getEntrypointName(config.entrypointsDir, path),
inputPath: path,
outputDir: config.outDir,
};
}
const withSameName = existingNames[entrypoint.name];
if (withSameName) {
throw Error(
`Multiple entrypoints with the name "${
entrypoint.name
}" detected, but only one is allowed: ${[
relative(config.root, withSameName.inputPath),
relative(config.root, entrypoint.inputPath),
].join(', ')}`,
);
}
entrypoints.push(entrypoint);
existingNames[entrypoint.name] = 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,
);
}
}
const mv2KeyContent = document
.querySelector("meta[name='manifest.type']")
?.getAttribute('content');
if (mv2KeyContent) {
options.mv2Key =
mv2KeyContent === 'page_action' ? 'page_action' : 'browser_action';
}
return {
type: 'popup',
name: 'popup',
options,
inputPath: path,
outputDir: config.outDir,
};
}
/**
* @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 openInTabContent = document
.querySelector("meta[name='manifest.open_in_tab']")
?.getAttribute('content');
if (openInTabContent) {
options.openInTab = Boolean(openInTabContent);
}
const chromeStyleContent = document
.querySelector("meta[name='manifest.chrome_style']")
?.getAttribute('content');
if (chromeStyleContent) {
options.chromeStyle = Boolean(chromeStyleContent);
}
const browserStyleContent = document
.querySelector("meta[name='manifest.browser_style']")
?.getAttribute('content');
if (browserStyleContent) {
options.browserStyle = Boolean(browserStyleContent);
}
return {
type: 'options',
name: 'options',
options,
inputPath: path,
outputDir: config.outDir,
};
}
/**
* @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>(config.root, path);
if (options == null) {
throw Error('Background script does not have a default export');
}
return {
type: 'background',
name: 'background',
inputPath: path,
outputDir: config.outDir,
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>(
config.root,
path,
);
if (options == null) {
throw Error(`Content script ${name} does not have a default export`);
}
return {
type: 'content-script',
name: getEntrypointName(config.entrypointsDir, path),
inputPath: path,
outputDir: resolve(config.outDir, 'content-scripts'),
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?(x)': 'content-script',
'*.content/index.ts?(x)': '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,
'root' | 'entrypointsDir' | 'outDir' | 'logger' | 'mode' | 'command'
>;