wxt
Version:
⚡ Next-gen Web Extension Framework
301 lines (300 loc) • 10.8 kB
JavaScript
import { CSS_EXTENSIONS_PATTERN } from "../paths.mjs";
import { getEntrypointName, isHtmlEntrypoint, isJsEntrypoint, resolvePerBrowserOptions } from "../entrypoints.mjs";
import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from "../constants.mjs";
import { wxt } from "../../wxt.mjs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { glob } from "tinyglobby";
import { relative, resolve } from "path";
import { parseHTML } from "linkedom";
import { camelCase } from "scule";
import { styleText } from "node:util";
import picomatch from "picomatch";
import JSON5 from "json5";
//#region src/core/utils/building/find-entrypoints.ts
/**
* Return entrypoints and their configuration by looking through the project's
* files.
*/
async function findEntrypoints() {
await mkdir(wxt.config.wxtDir, { recursive: true });
try {
await writeFile(resolve(wxt.config.wxtDir, "tsconfig.json"), JSON.stringify({}), { flag: "wx" });
} catch (err) {
if (!(err instanceof Error) || !("code" in err) || err.code !== "EEXIST") throw err;
}
const relativePaths = await glob(Object.keys(PATH_GLOB_TO_TYPE_MAP), {
cwd: wxt.config.entrypointsDir,
expandDirectories: false
});
relativePaths.sort();
const pathGlobs = Object.keys(PATH_GLOB_TO_TYPE_MAP);
const entrypointInfos = relativePaths.reduce((results, relativePath) => {
const inputPath = resolve(wxt.config.entrypointsDir, relativePath);
const name = getEntrypointName(wxt.config.entrypointsDir, inputPath);
const matchingGlob = pathGlobs.find((glob) => picomatch(glob)(relativePath));
if (matchingGlob) {
const type = PATH_GLOB_TO_TYPE_MAP[matchingGlob];
results.push({
name,
inputPath,
type
});
}
return results;
}, []).filter(({ name, inputPath }, _, entrypointInfos) => {
if (inputPath.endsWith(".html")) return true;
if (!/index\..+$/.test(inputPath)) return true;
return !entrypointInfos.some((entry) => entry.name === name && entry.inputPath.endsWith("index.html"));
});
await wxt.hooks.callHook("entrypoints:found", wxt, entrypointInfos);
preventNoEntrypoints(entrypointInfos);
preventDuplicateEntrypointNames(entrypointInfos);
let hasBackground = false;
const entrypointOptions = await importEntrypoints(entrypointInfos);
const entrypointsWithoutSkipped = await Promise.all(entrypointInfos.map(async (info) => {
const { type } = info;
const options = entrypointOptions[info.inputPath] ?? {};
switch (type) {
case "popup": return await getPopupEntrypoint(info, options);
case "sidepanel": return await getSidepanelEntrypoint(info, options);
case "options": return await getOptionsEntrypoint(info, options);
case "background":
hasBackground = true;
return await getBackgroundEntrypoint(info, options);
case "content-script": return await getContentScriptEntrypoint(info, options);
case "unlisted-page": return await getUnlistedPageEntrypoint(info, options);
case "unlisted-script": return await getUnlistedScriptEntrypoint(info, options);
case "content-script-style": return {
...info,
type,
outputDir: resolve(wxt.config.outDir, CONTENT_SCRIPT_OUT_DIR),
options
};
default: return {
...info,
type,
outputDir: wxt.config.outDir,
options
};
}
}));
if (wxt.config.command === "serve" && !hasBackground) entrypointsWithoutSkipped.push(await getBackgroundEntrypoint({
inputPath: VIRTUAL_NOOP_BACKGROUND_MODULE_ID,
name: "background",
type: "background"
}, {}));
const entrypoints = entrypointsWithoutSkipped.map((entry) => ({
...entry,
skipped: isEntrypointSkipped(entry)
}));
await wxt.hooks.callHook("entrypoints:resolved", wxt, entrypoints);
wxt.logger.debug("All entrypoints:", entrypoints);
const skippedEntrypointNames = entrypoints.filter((item) => item.skipped).map((item) => item.name);
if (skippedEntrypointNames.length) wxt.logger.warn(["The following entrypoints have been skipped:", ...skippedEntrypointNames.map((item) => `${styleText("dim", "-")} ${styleText("cyan", item)}`)].join("\n"));
return entrypoints;
}
/** Returns a map of input paths to the file's options. */
async function importEntrypoints(infos) {
const resMap = {};
const htmlInfos = infos.filter((info) => isHtmlEntrypoint(info));
const jsInfos = infos.filter((info) => isJsEntrypoint(info));
await Promise.all([...htmlInfos.map(async (info) => {
resMap[info.inputPath] = await importHtmlEntrypoint(info);
}), (async () => {
(await wxt.builder.importEntrypoints(jsInfos.map((info) => info.inputPath))).forEach((res, i) => {
resMap[jsInfos[i].inputPath] = res;
});
})()]);
return resMap;
}
/**
* Extract `manifest.` and `wxt.` options from meta tags, converting snake_case
* keys to camelCase
*/
async function importHtmlEntrypoint(info) {
const { document } = parseHTML(await readFile(info.inputPath, "utf-8"));
const metaTags = document.querySelectorAll("meta");
const res = { title: document.querySelector("title")?.textContent || void 0 };
metaTags.forEach((tag) => {
const name = tag.name;
let key;
if (name.startsWith("manifest.")) key = camelCase(name.slice(9));
else if (name.startsWith("wxt.")) key = camelCase(name.slice(4));
else return;
try {
res[key] = JSON5.parse(tag.content);
} catch {
res[key] = tag.content;
}
});
return res;
}
function preventDuplicateEntrypointNames(files) {
const namesToPaths = files.reduce((map, { name, inputPath }) => {
map[name] ??= [];
map[name].push(inputPath);
return map;
}, {});
const errorLines = Object.entries(namesToPaths).reduce((lines, [name, absolutePaths]) => {
if (absolutePaths.length > 1) {
lines.push(`- ${name}`);
absolutePaths.forEach((absolutePath) => {
lines.push(` - ${relative(wxt.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(files) {
if (files.length === 0) throw Error(`No entrypoints found in ${wxt.config.entrypointsDir}`);
}
async function getPopupEntrypoint(info, options) {
const { themeIcons, title, type, ...perBrowserOptions } = options;
const strictOptions = resolvePerBrowserOptions({
...perBrowserOptions,
defaultTitle: title,
actionType: type
}, wxt.config.browser);
if (strictOptions.actionType && strictOptions.actionType !== "page_action") strictOptions.actionType = "browser_action";
const opts = {
...strictOptions,
themeIcons
};
let _actionType = opts.actionType;
Object.defineProperty(opts, "actionType", {
get: () => _actionType,
set: (v) => _actionType = v,
enumerable: true,
configurable: true
});
Object.defineProperty(opts, "mv2Key", {
get: () => _actionType,
set: (v) => _actionType = v,
enumerable: true,
configurable: true
});
return {
type: "popup",
name: "popup",
options: opts,
inputPath: info.inputPath,
outputDir: wxt.config.outDir
};
}
async function getOptionsEntrypoint(info, options) {
return {
type: "options",
name: "options",
options: resolvePerBrowserOptions(options, wxt.config.browser),
inputPath: info.inputPath,
outputDir: wxt.config.outDir
};
}
async function getUnlistedPageEntrypoint(info, options) {
return {
type: "unlisted-page",
name: info.name,
inputPath: info.inputPath,
outputDir: wxt.config.outDir,
options
};
}
async function getUnlistedScriptEntrypoint({ inputPath, name }, options) {
return {
type: "unlisted-script",
name,
inputPath,
outputDir: wxt.config.outDir,
options: resolvePerBrowserOptions(options, wxt.config.browser)
};
}
async function getBackgroundEntrypoint({ inputPath, name }, options) {
const strictOptions = resolvePerBrowserOptions(options, wxt.config.browser);
if (wxt.config.manifestVersion !== 3) delete strictOptions.type;
return {
type: "background",
name,
inputPath,
outputDir: wxt.config.outDir,
options: strictOptions
};
}
async function getContentScriptEntrypoint({ inputPath, name }, options) {
return {
type: "content-script",
name,
inputPath,
outputDir: resolve(wxt.config.outDir, CONTENT_SCRIPT_OUT_DIR),
options: resolvePerBrowserOptions(options, wxt.config.browser)
};
}
async function getSidepanelEntrypoint(info, options) {
const { title, ...perBrowserOptions } = options;
return {
type: "sidepanel",
name: info.name,
options: resolvePerBrowserOptions({
...perBrowserOptions,
defaultTitle: title
}, wxt.config.browser),
inputPath: info.inputPath,
outputDir: wxt.config.outDir
};
}
function isEntrypointSkipped(entry) {
if (wxt.config.filterEntrypoints != null) return !wxt.config.filterEntrypoints.has(entry.name);
const { include, exclude } = entry.options;
if (include?.length && exclude?.length) {
wxt.logger.warn(`The ${entry.name} entrypoint lists both include and exclude, but only one can be used per entrypoint. Entrypoint skipped.`);
return true;
}
if (exclude?.length && !include?.length) return exclude.includes(wxt.config.browser);
if (include?.length && !exclude?.length) return !include.includes(wxt.config.browser);
return false;
}
const PATH_GLOB_TO_TYPE_MAP = {
"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";
//#endregion
export { findEntrypoints };