wxt
Version:
⚡ Next-gen Web Extension Framework
391 lines (389 loc) • 12 kB
JavaScript
import { relative, resolve } from "path";
import fs from "fs-extra";
import { minimatch } from "minimatch";
import { parseHTML } from "linkedom";
import JSON5 from "json5";
import glob from "fast-glob";
import {
getEntrypointName,
isHtmlEntrypoint,
isJsEntrypoint,
resolvePerBrowserOptions
} from "../../utils/entrypoints.mjs";
import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from "../../utils/constants.mjs";
import { CSS_EXTENSIONS_PATTERN } from "../../utils/paths.mjs";
import pc from "picocolors";
import { wxt } from "../../wxt.mjs";
import { camelCase } from "scule";
export async function findEntrypoints() {
await fs.mkdir(wxt.config.wxtDir, { recursive: true });
try {
await fs.writeJson(
resolve(wxt.config.wxtDir, "tsconfig.json"),
{},
{ 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
});
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(
(glob2) => minimatch(relativePath, glob2)
);
if (matchingGlob) {
const type = PATH_GLOB_TO_TYPE_MAP[matchingGlob];
results.push({ name, inputPath, type });
}
return results;
}, []).filter(({ name, inputPath }, _, entrypointInfos2) => {
if (inputPath.endsWith(".html")) return true;
const isIndexFile = /index\..+$/.test(inputPath);
if (!isIndexFile) return true;
const hasIndexHtml = entrypointInfos2.some(
(entry) => entry.name === name && entry.inputPath.endsWith("index.html")
);
if (hasIndexHtml) return false;
return true;
});
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: {
include: options.include,
exclude: options.exclude
}
};
default:
return {
...info,
type,
outputDir: wxt.config.outDir,
options: {
include: options.include,
exclude: options.exclude
}
};
}
})
);
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) => `${pc.dim("-")} ${pc.cyan(item)}`
)
].join("\n")
);
}
return entrypoints;
}
async function importEntrypoints(infos) {
const resMap = {};
const htmlInfos = infos.filter((info) => isHtmlEntrypoint(info));
const jsInfos = infos.filter((info) => isJsEntrypoint(info));
await Promise.all([
// HTML
...htmlInfos.map(async (info) => {
const res = await importHtmlEntrypoint(info);
resMap[info.inputPath] = res;
}),
// JS
(async () => {
const res = await wxt.builder.importEntrypoints(
jsInfos.map((info) => info.inputPath)
);
res.forEach((res2, i) => {
resMap[jsInfos[i].inputPath] = res2;
});
})()
// CSS - never has options
]);
return resMap;
}
async function importHtmlEntrypoint(info) {
const content = await fs.readFile(info.inputPath, "utf-8");
const { document } = parseHTML(content);
const metaTags = document.querySelectorAll("meta");
const res = {
title: document.querySelector("title")?.textContent || void 0
};
metaTags.forEach((tag) => {
const name = tag.name;
if (!name.startsWith("manifest.")) return;
const key = camelCase(name.slice(9));
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.
${errorContent}`
);
}
}
function preventNoEntrypoints(files) {
if (files.length === 0) {
throw Error(`No entrypoints found in ${wxt.config.entrypointsDir}`);
}
}
async function getPopupEntrypoint(info, options) {
const stictOptions = resolvePerBrowserOptions(
{
browserStyle: options.browserStyle,
exclude: options.exclude,
include: options.include,
defaultIcon: options.defaultIcon,
defaultTitle: options.title,
mv2Key: options.type
},
wxt.config.browser
);
if (stictOptions.mv2Key && stictOptions.mv2Key !== "page_action")
stictOptions.mv2Key = "browser_action";
return {
type: "popup",
name: "popup",
options: stictOptions,
inputPath: info.inputPath,
outputDir: wxt.config.outDir
};
}
async function getOptionsEntrypoint(info, options) {
return {
type: "options",
name: "options",
options: resolvePerBrowserOptions(
{
browserStyle: options.browserStyle,
chromeStyle: options.chromeStyle,
exclude: options.exclude,
include: options.include,
openInTab: options.openInTab
},
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: {
include: options.include,
exclude: options.exclude
}
};
}
async function getUnlistedScriptEntrypoint({ inputPath, name }, options) {
return {
type: "unlisted-script",
name,
inputPath,
outputDir: wxt.config.outDir,
options: resolvePerBrowserOptions(
{
include: options.include,
exclude: options.exclude
},
wxt.config.browser
)
};
}
async function getBackgroundEntrypoint({ inputPath, name }, options) {
const strictOptions = resolvePerBrowserOptions(
{
include: options.include,
exclude: options.exclude,
persistent: options.persistent,
type: options.type
},
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) {
return {
type: "sidepanel",
name: info.name,
options: resolvePerBrowserOptions(
{
browserStyle: options.browserStyle,
exclude: options.exclude,
include: options.include,
defaultIcon: options.defaultIcon,
defaultTitle: options.title,
openAtInstall: options.openAtInstall
},
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";