UNPKG

wxt

Version:

⚡ Next-gen Web Extension Framework

301 lines (300 loc) 10.8 kB
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 };