Skip to main content
Module

x/lume/plugins/esbuild.ts

πŸ”₯ Static site generator for Deno πŸ¦•
Very Popular
Latest
File
import { isAbsolutePath, isUrl, normalizePath, replaceExtension,} from "../core/utils/path.ts";import { merge } from "../core/utils/object.ts";import { readDenoConfig } from "../core/utils/deno_config.ts";import { log } from "../core/utils/log.ts";import { read } from "../core/utils/read.ts";import { concurrent } from "../core/utils/concurrent.ts";import { build, BuildOptions, OutputFile, stop } from "../deps/esbuild.ts";import { extname, fromFileUrl, posix, toFileUrl } from "../deps/path.ts";import { prepareAsset, saveAsset } from "./source_maps.ts";import { Page } from "../core/file.ts";import textLoader from "../core/loaders/text.ts";
import type Site from "../core/site.ts";import type { DenoConfig } from "../core/utils/deno_config.ts";
export interface Options { /** The list of extensions this plugin applies to */ extensions?: string[];
/** * Global options for esm.sh CDN used to fetch NPM packages * @see https://esm.sh/#docs */ esm?: EsmOptions;
/** * The options for esbuild * @see https://esbuild.github.io/api/#general-options */ options?: BuildOptions;}
export interface EsmOptions { /** To include the ?dev option to all packages */ dev?: boolean;
/** Configure the cjs-exports option for each package */ cjsExports?: Record<string, string[] | string>;
/** Configure the deps for each package */ deps?: Record<string, string[] | string>;}
const denoConfig = await readDenoConfig();
// Default optionsexport const defaults: Options = { extensions: [".ts", ".js"], esm: {}, options: { plugins: [], bundle: true, format: "esm", minify: true, keepNames: true, platform: "browser", target: "esnext", treeShaking: true, },};
const contentSymbol = Symbol.for("contentSymbol");
interface LumeBuildOptions extends BuildOptions { [contentSymbol]: Record<string, string>;}
export default function (userOptions?: Options) { const options = merge(defaults, userOptions);
// Configure jsx automatically if ( options.extensions.some((ext) => ext.endsWith(".tsx") || ext.endsWith(".jsx") ) ) { options.options = { ...buildJsxConfig(denoConfig?.config), ...options.options, }; }
// Sync the jsxDev option with esm.dev if (options.esm.dev) { options.options.jsxDev = true; } else if (options.options.jsxDev) { options.esm.dev = true; }
return (site: Site) => { site.loadAssets(options.extensions);
function resolve(path: string) { // "npm:" specifiers are currently not supported in import.meta.resolve() // https://github.com/denoland/deno/issues/21298 if (!path.startsWith("npm:")) { path = import.meta.resolve(path); }
if (!path.startsWith("npm:")) { return { path, namespace: "deno", }; }
const name = path.replace(/^npm:/, ""); const url = new URL(`https://esm.sh/${name}`);
if (options.esm.dev) { url.searchParams.set("dev", "true"); }
// cjs exports const cjs_exports = options.esm.cjsExports?.[name];
if (cjs_exports) { url.searchParams.set( "cjs-exports", Array.isArray(cjs_exports) ? cjs_exports.join(",") : cjs_exports, ); }
// deps const deps = options.esm.deps?.[name];
if (deps) { url.searchParams.set( "deps", Array.isArray(deps) ? deps.join(",") : deps, ); }
return { path: url.href, namespace: "deno", }; }
const prefix = toFileUrl(site.src()).href; const lumeLoaderPlugin = { name: "lumeLoader", // deno-lint-ignore no-explicit-any setup(build: any) { const { initialOptions } = build; build.onResolve({ filter: /.*/ }, (args: ResolveArguments) => { const { path, importer } = args;
// Absolute url if (isUrl(path)) { return resolve(path); }
// Resolve the relative url if (isUrl(importer) && path.match(/^[./]/)) { return resolve(new URL(path, importer).href); }
// It's a npm package if (path.startsWith("npm:")) { return resolve(path); }
if (!isUrl(path)) { return resolve(isAbsolutePath(path) ? toFileUrl(path).href : path); }
return resolve(path); });
build.onLoad({ filter: /.*/ }, async (args: LoadArguments) => { let { path, namespace } = args;
// It's one of the entry point files if (initialOptions[contentSymbol][path]) { return { contents: initialOptions[contentSymbol][path], loader: getLoader(path), }; }
// Read files from Lume if (namespace === "deno") { if (path.startsWith(prefix)) { const file = path.replace(prefix, ""); const content = await site.getContent(file, textLoader);
if (content) { return { contents: content, loader: getLoader(path), }; } } }
// Convert file:// urls to paths if (path.startsWith("file://")) { path = normalizePath(fromFileUrl(path)); }
// Read other files from the filesystem/url const content = await readFile(path); return { contents: content, loader: getLoader(path), }; }); }, }; options.options.plugins?.push(lumeLoaderPlugin);
site.hooks.addEsbuildPlugin = (plugin) => { options.options.plugins?.unshift(plugin); };
site.addEventListener("beforeSave", stop);
/** Run esbuild and returns the output files */ async function runEsbuild( pages: Page[], extraOptions: BuildOptions = {}, ): Promise<[OutputFile[], boolean]> { let enableAllSourceMaps = false; const entryContent: Record<string, string> = {}; const entryPoints: string[] = [];
pages.forEach((page) => { const { content, filename, enableSourceMap } = prepareAsset(site, page); if (enableSourceMap) { enableAllSourceMaps = true; } entryPoints.push(filename); entryContent[toFileUrl(filename).href] = content; });
const buildOptions: LumeBuildOptions = { ...options.options, write: false, metafile: false, entryPoints, sourcemap: enableAllSourceMaps ? "external" : undefined, ...extraOptions, [contentSymbol]: entryContent, };
const { outputFiles, warnings, errors } = await build( // @ts-expect-error: esbuild uses a SameShape type to prevent the passing // of extra options (which we use to pass the entryContent) buildOptions, );
if (errors.length) { log.error(`[esbuild plugin] Build errors: \n${errors.join("\n")}`); }
if (warnings.length) { log.warn( `[esbuild plugin] Build warnings: \n${warnings.join("\n")}`, ); }
return [outputFiles || [], enableAllSourceMaps]; }
// Splitting mode needs to run esbuild with all pages at the same time if (options.options.splitting) { // Define default options for splitting mode options.options.absWorkingDir ||= site.src(); options.options.outdir ||= "./"; options.options.outbase ||= "."; const basePath = options.options.absWorkingDir;
site.process(options.extensions, async (pages, allPages) => { const [outputFiles, enableSourceMap] = await runEsbuild(pages);
// Save the output code for (const file of outputFiles) { if (file.path.endsWith(".map")) { continue; }
// Search the entry point of this output file const url = normalizePath( normalizePath(file.path).replace(basePath, ""), ); const urlWithoutExt = pathWithoutExtension(url); const entryPoint = pages.find((page) => { const outdir = posix.join( "/", options.options.outdir || ".", pathWithoutExtension(page.data.url), );
return outdir === urlWithoutExt; });
// Get the associated source map const map = enableSourceMap ? outputFiles.find((f) => f.path === `${file.path}.map`) : undefined;
// The page is an entry point if (entryPoint) { entryPoint.data.url = url; // Update the url to .js extension saveAsset(site, entryPoint, file.text, map?.text); } else { // The page is a chunk const page = Page.create({ url }); saveAsset(site, page, file.text, map?.text); allPages.push(page); } } }); } else { // Normal mode runs esbuild for each page site.process( options.extensions, (pages) => concurrent(pages, async (page) => { const [outputFiles] = await runEsbuild([page], { outfile: replaceExtension(page.outputPath, ".js"), });
let mapFile: OutputFile | undefined; let jsFile: OutputFile | undefined;
for (const file of outputFiles) { if (file.path.endsWith(".map")) { mapFile = file; } else { jsFile = file; } }
saveAsset(site, page, jsFile?.text!, mapFile?.text); page.data.url = replaceExtension(page.data.url, ".js"); }), ); } };}
function getLoader(path: string) { const ext = extname(path).toLowerCase();
switch (ext) { case ".ts": case ".mts": return "ts"; case ".tsx": return "tsx"; case ".jsx": return "jsx"; case ".json": return "json"; default: return "js"; }}
interface LoadArguments { path: string; namespace: string; suffix: string; pluginData: unknown;}
interface ResolveArguments { path: string; importer: string; namespace: string; resolveDir: string; kind: string; pluginData: unknown;}
function buildJsxConfig(config?: DenoConfig): BuildOptions | undefined { if (!config) { return; }
const { compilerOptions } = config;
if (compilerOptions?.jsxImportSource) { return { jsx: "automatic", jsxImportSource: compilerOptions.jsxImportSource, }; }}
function pathWithoutExtension(path: string): string { return path.replace(/\.\w+$/, "");}
export async function readFile(path: string): Promise<string> { return await read(path, false, { headers: { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/115.0", }, });}