Files
wxt/src/core/utils/building/find-entrypoints.ts
T

452 lines
13 KiB
TypeScript

import { relative, resolve } from 'path';
import {
BackgroundEntrypoint,
BackgroundDefinition,
BaseEntrypointOptions,
ContentScriptDefinition,
ContentScriptEntrypoint,
Entrypoint,
GenericEntrypoint,
InternalConfig,
OptionsEntrypoint,
PopupEntrypoint,
UnlistedScriptDefinition,
} from '~/types';
import fs from 'fs-extra';
import { minimatch } from 'minimatch';
import { parseHTML } from 'linkedom';
import JSON5 from 'json5';
import { importEntrypointFile } from '~/core/utils/building';
import glob from 'fast-glob';
import {
getEntrypointName,
resolvePerBrowserOption,
} from '~/core/utils/entrypoints';
import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '~/core/utils/constants';
import { CSS_EXTENSIONS_PATTERN } from '~/core/utils/paths';
/**
* Return entrypoints and their configuration by looking through the project's files.
*/
export async function findEntrypoints(
config: InternalConfig,
): Promise<Entrypoint[]> {
const relativePaths = await glob(Object.keys(PATH_GLOB_TO_TYPE_MAP), {
cwd: config.entrypointsDir,
});
// Ensure consistent output
relativePaths.sort();
const pathGlobs = Object.keys(PATH_GLOB_TO_TYPE_MAP);
const entrypointInfos: EntrypointInfo[] = relativePaths.reduce<
EntrypointInfo[]
>((results, relativePath) => {
const inputPath = resolve(config.entrypointsDir, relativePath);
const name = getEntrypointName(config.entrypointsDir, inputPath);
const matchingGlob = pathGlobs.find((glob) =>
minimatch(relativePath, glob),
);
if (matchingGlob) {
const type = PATH_GLOB_TO_TYPE_MAP[matchingGlob];
results.push({ name, inputPath, type });
}
return results;
}, []);
// Validation
preventNoEntrypoints(config, entrypointInfos);
preventDuplicateEntrypointNames(config, entrypointInfos);
// Import entrypoints to get their config
let hasBackground = false;
const entrypoints: Entrypoint[] = await Promise.all(
entrypointInfos.map(async (info): Promise<Entrypoint> => {
const { type } = info;
switch (type) {
case 'popup':
return await getPopupEntrypoint(config, info);
case 'options':
return await getOptionsEntrypoint(config, info);
case 'background':
hasBackground = true;
return await getBackgroundEntrypoint(config, info);
case 'content-script':
return await getContentScriptEntrypoint(config, info);
case 'unlisted-page':
return await getUnlistedPageEntrypoint(config, info);
case 'unlisted-script':
return await getUnlistedScriptEntrypoint(config, info);
case 'content-script-style':
return {
...info,
type,
outputDir: resolve(config.outDir, CONTENT_SCRIPT_OUT_DIR),
options: {
include: undefined,
exclude: undefined,
},
};
default:
return {
...info,
type,
outputDir: config.outDir,
options: {
include: undefined,
exclude: undefined,
},
};
}
}),
);
if (config.command === 'serve' && !hasBackground) {
entrypoints.push(
await getBackgroundEntrypoint(config, {
inputPath: VIRTUAL_NOOP_BACKGROUND_MODULE_ID,
name: 'background',
type: 'background',
}),
);
}
config.logger.debug('All entrypoints:', entrypoints);
const targetEntrypoints = entrypoints.filter((entry) => {
const { include, exclude } = entry.options;
if (include?.length && exclude?.length) {
config.logger.warn(
`The ${entry.name} entrypoint lists both include and exclude, but only one can be used per entrypoint. Entrypoint ignored.`,
);
return false;
}
if (exclude?.length && !include?.length) {
return !exclude.includes(config.browser);
}
if (include?.length && !exclude?.length) {
return include.includes(config.browser);
}
return true;
});
config.logger.debug(`${config.browser} entrypoints:`, targetEntrypoints);
return targetEntrypoints;
}
interface EntrypointInfo {
name: string;
inputPath: string;
type: Entrypoint['type'];
}
function preventDuplicateEntrypointNames(
config: InternalConfig,
files: EntrypointInfo[],
) {
const namesToPaths = files.reduce<Record<string, string[]>>(
(map, { name, inputPath }) => {
map[name] ??= [];
map[name].push(inputPath);
return map;
},
{},
);
const errorLines = Object.entries(namesToPaths).reduce<string[]>(
(lines, [name, absolutePaths]) => {
if (absolutePaths.length > 1) {
lines.push(`- ${name}`);
absolutePaths.forEach((absolutePath) => {
lines.push(` - ${relative(config.root, absolutePath)}`);
});
}
return lines;
},
[],
);
if (errorLines.length > 0) {
const errorContent = errorLines.join('\n');
throw Error(
`Multiple entrypoints with the same name detected, only one entrypoint for each name is allowed.\n\n${errorContent}`,
);
}
}
function preventNoEntrypoints(config: InternalConfig, files: EntrypointInfo[]) {
if (files.length === 0) {
throw Error(`No entrypoints found in ${config.entrypointsDir}`);
}
}
function getHtmlBaseOptions(document: Document): BaseEntrypointOptions {
const options: BaseEntrypointOptions = {};
const includeContent = document
.querySelector("meta[name='manifest.include']")
?.getAttribute('content');
if (includeContent) {
options.include = JSON5.parse(includeContent);
}
const excludeContent = document
.querySelector("meta[name='manifest.exclude']")
?.getAttribute('content');
if (excludeContent) {
options.exclude = JSON5.parse(excludeContent);
}
return options;
}
/**
* @param path Absolute path to the popup HTML file.
* @param content String contents of the file at the path.
*/
async function getPopupEntrypoint(
config: InternalConfig,
{ inputPath, name }: EntrypointInfo,
): Promise<PopupEntrypoint> {
const content = await fs.readFile(inputPath, 'utf-8');
const { document } = parseHTML(content);
const options: PopupEntrypoint['options'] = getHtmlBaseOptions(document);
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 mv2TypeContent = document
.querySelector("meta[name='manifest.type']")
?.getAttribute('content');
if (mv2TypeContent) {
options.mv2Key =
mv2TypeContent === 'page_action' ? 'page_action' : 'browser_action';
}
const browserStyleContent = document
.querySelector("meta[name='manifest.browser_style']")
?.getAttribute('content');
if (browserStyleContent) {
options.browserStyle = browserStyleContent === 'true';
}
return {
type: 'popup',
name: 'popup',
options,
inputPath,
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: InternalConfig,
{ inputPath, name }: EntrypointInfo,
): Promise<OptionsEntrypoint> {
const content = await fs.readFile(inputPath, 'utf-8');
const { document } = parseHTML(content);
const options: OptionsEntrypoint['options'] = getHtmlBaseOptions(document);
const openInTabContent = document
.querySelector("meta[name='manifest.open_in_tab']")
?.getAttribute('content');
if (openInTabContent) {
options.openInTab = openInTabContent === 'true';
}
const chromeStyleContent = document
.querySelector("meta[name='manifest.chrome_style']")
?.getAttribute('content');
if (chromeStyleContent) {
options.chromeStyle = chromeStyleContent === 'true';
}
const browserStyleContent = document
.querySelector("meta[name='manifest.browser_style']")
?.getAttribute('content');
if (browserStyleContent) {
options.browserStyle = browserStyleContent === 'true';
}
return {
type: 'options',
name: 'options',
options,
inputPath,
outputDir: config.outDir,
};
}
/**
* @param path Absolute path to the HTML file.
* @param content String contents of the file at the path.
*/
async function getUnlistedPageEntrypoint(
config: InternalConfig,
{ inputPath, name }: EntrypointInfo,
): Promise<GenericEntrypoint> {
const content = await fs.readFile(inputPath, 'utf-8');
const { document } = parseHTML(content);
return {
type: 'unlisted-page',
name: getEntrypointName(config.entrypointsDir, inputPath),
inputPath,
outputDir: config.outDir,
options: getHtmlBaseOptions(document),
};
}
/**
* @param path Absolute path to the script's file.
* @param content String contents of the file at the path.
*/
async function getUnlistedScriptEntrypoint(
config: InternalConfig,
{ inputPath, name }: EntrypointInfo,
): Promise<GenericEntrypoint> {
const defaultExport = await importEntrypointFile<UnlistedScriptDefinition>(
inputPath,
config,
);
if (defaultExport == null) {
throw Error(
`${name}: Default export not found, did you forget to call "export default defineUnlistedScript(...)"?`,
);
}
const { main: _, ...moduleOptions } = defaultExport;
const options: Omit<UnlistedScriptDefinition, 'main'> = moduleOptions;
return {
type: 'unlisted-script',
name,
inputPath,
outputDir: config.outDir,
options,
};
}
/**
* @param path Absolute path to the background's TS file.
*/
async function getBackgroundEntrypoint(
config: InternalConfig,
{ inputPath, name }: EntrypointInfo,
): Promise<BackgroundEntrypoint> {
let options: Omit<BackgroundDefinition, 'main'> = {};
if (inputPath !== VIRTUAL_NOOP_BACKGROUND_MODULE_ID) {
const defaultExport = await importEntrypointFile<BackgroundDefinition>(
inputPath,
config,
);
if (defaultExport == null) {
throw Error(
`${name}: Default export not found, did you forget to call "export default defineBackground(...)"?`,
);
}
const { main: _, ...moduleOptions } = defaultExport;
options = moduleOptions;
}
return {
type: 'background',
name,
inputPath,
outputDir: config.outDir,
options: {
...options,
type: resolvePerBrowserOption(options.type, config.browser),
persistent: resolvePerBrowserOption(options.persistent, config.browser),
},
};
}
/**
* @param path Absolute path to the content script's TS file.
*/
async function getContentScriptEntrypoint(
config: InternalConfig,
{ inputPath, name }: EntrypointInfo,
): Promise<ContentScriptEntrypoint> {
const { main: _, ...options } =
await importEntrypointFile<ContentScriptDefinition>(inputPath, config);
if (options == null) {
throw Error(
`${name}: Default export not found, did you forget to call "export default defineContentScript(...)"?`,
);
}
return {
type: 'content-script',
name,
inputPath,
outputDir: resolve(config.outDir, CONTENT_SCRIPT_OUT_DIR),
options,
};
}
const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type']> = {
'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.[jt]s': 'background',
'background/index.[jt]s': 'background',
[VIRTUAL_NOOP_BACKGROUND_MODULE_ID]: 'background',
'content.[jt]s?(x)': 'content-script',
'content/index.[jt]s?(x)': 'content-script',
'*.content.[jt]s?(x)': 'content-script',
'*.content/index.[jt]s?(x)': 'content-script',
[`content.${CSS_EXTENSIONS_PATTERN}`]: 'content-script-style',
[`*.content.${CSS_EXTENSIONS_PATTERN}`]: 'content-script-style',
[`content/index.${CSS_EXTENSIONS_PATTERN}`]: 'content-script-style',
[`*.content/index.${CSS_EXTENSIONS_PATTERN}`]: 'content-script-style',
'popup.html': 'popup',
'popup/index.html': 'popup',
'options.html': 'options',
'options/index.html': 'options',
'*.html': 'unlisted-page',
'*/index.html': 'unlisted-page',
'*.[jt]s?(x)': 'unlisted-script',
'*/index.[jt]s?(x)': 'unlisted-script',
[`*.${CSS_EXTENSIONS_PATTERN}`]: 'unlisted-style',
[`*/index.${CSS_EXTENSIONS_PATTERN}`]: 'unlisted-style',
};
const CONTENT_SCRIPT_OUT_DIR = 'content-scripts';