import {
MediaType,
RequestedModuleType,
ResolutionMode,
Workspace,
} from "../deps/deno_loader.ts";
import {
getPathAndExtension,
isAbsolutePath,
normalizePath,
} from "../core/utils/path.ts";
import { merge } from "../core/utils/object.ts";
import { log, warnUntil } from "../core/utils/log.ts";
import { bytes } from "../core/utils/format.ts";
import { browsers, versionString } from "../core/utils/browsers.ts";
import {
build,
BuildOptions,
Loader,
Metafile,
OnResolveArgs,
OutputFile,
stop,
} from "../deps/esbuild.ts";
import {
extname,
fromFileUrl,
posix,
SEPARATOR,
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 { isBuiltin } from "node:module";
import type Site from "../core/site.ts";
export interface Options {
/** File extensions to bundle */
extensions?: string[];
/**
* The options for esbuild
* @see https://esbuild.github.io/api/#general-options
*/
options?: BuildOptions;
/**
* The Deno config file to use
*/
denoConfig?: string;
}
// Default options
export const defaults: Options = {
extensions: [".ts", ".js", ".tsx", ".jsx"],
options: {
plugins: [],
bundle: true,
format: "esm",
minify: true,
keepNames: true,
platform: "browser",
target: [
`chrome${versionString(browsers.chrome)}`,
`edge${versionString(browsers.edge)}`,
`firefox${versionString(browsers.firefox)}`,
`ios${versionString(browsers.safari_ios)}`,
`safari${versionString(browsers.safari)}`,
],
treeShaking: true,
jsx: "automatic",
outdir: "./",
outbase: ".",
},
};
let resolver: ((specifier: string, referrer?: string) => string) | undefined;
interface EntryPoint {
in: string;
out: string;
content: string;
}
/**
* A plugin to use esbuild in Lume
* @see https://lume.land/plugins/esbuild/
*/
export function esbuild(userOptions?: Options) {
const options = merge(defaults, userOptions);
return (site: Site) => {
site.hooks.addEsbuildPlugin = (plugin) => {
options.options.plugins!.unshift(plugin);
};
const basePath = options.options.absWorkingDir || site.src();
let configPath: string | undefined;
// Use deno.json to configure esbuild options
try {
const denoJson = options.denoConfig
? site.root(options.denoConfig)
: site.root("deno.json");
const content = Deno.readTextFileSync(denoJson);
const config = JSON.parse(content);
const { compilerOptions } = config;
options.options.jsxImportSource ??= compilerOptions.jsxImportSource;
options.options.jsx ??= compilerOptions.jsx;
configPath = denoJson;
} catch {
// deno.json doesn't exist.
}
async function runEsbuild(
pages: Page[],
): Promise<[OutputFile[], Metafile, boolean]> {
let sourcemap;
const entryPoints: EntryPoint[] = [];
pages.forEach((page) => {
const { content, filename, enableSourceMap } = prepareAsset(site, page);
if (enableSourceMap) {
sourcemap = "external";
}
let [out] = getPathAndExtension(page.outputPath);
if (out.startsWith("/")) {
out = out.slice(1); // This prevents Esbuild to generate urls with _.._/_.._/
}
entryPoints.push({ in: toFileUrl(filename).toString(), out, content });
});
const externals = (options.options.external ?? []).map(externalToRegex);
const buildOptions: BuildOptions = {
...options.options,
write: false,
metafile: true,
absWorkingDir: basePath,
entryPoints: entryPoints.map((p) => ({ in: p.in, out: p.out })),
sourcemap,
};
buildOptions.plugins = [...options.options.plugins || []];
buildOptions.plugins!.push(
{
name: "lume-loader",
async setup(build) {
const workspace = new Workspace({
configPath,
nodeConditions: build.initialOptions.conditions,
});
const loader = await workspace.createLoader();
await loader.addEntrypoints(entryPoints.map((ep) => ep.in));
async function onResolve(
{ kind, path, importer, ...rest }: OnResolveArgs,
) {
// Entry points are already loaded by Lume
if (kind === "entry-point") {
const entryPoint = entryPoints.find((entry) =>
entry.in === path
);
return {
path: path.startsWith("file:") ? fromFileUrl(path) : path,
namespace: "file",
pluginData: { entryPoint },
};
}
// Handle path aliases defined in build options
const aliased = buildOptions.alias?.[path];
if (aliased) {
if (aliased.startsWith(".")) {
return onResolve({
kind,
path: site.root(aliased),
importer,
...rest,
});
}
return onResolve({
kind,
path: aliased,
importer: site.root(),
...rest,
});
}
// Handle node built-in modules
if (isBuiltin(path)) {
log.warn(
`[esbuild plugin] "${path}", imported by ${importer} is a Node.js built-in module and won't work in the browser.`,
);
return {
path,
external: true,
};
}
// Handle external modules defined in build options
if (externals.some((reg) => reg.test(path))) {
return {
path,
external: true,
};
}
// Other imports are resolved by Deno loader
const mode = kind === "require-call" || kind === "require-resolve"
? ResolutionMode.Require
: ResolutionMode.Import;
// Ensure that we're dealing with a specifier, not a standard
// file path. This is needed for Windows paths.
const specifier = SEPARATOR === "\\" && isAbsolutePath(path)
? toFileUrl(path).href
: path;
const res = await loader.resolve(specifier, importer, mode);
let namespace: string | undefined;
if (res.startsWith("file:")) {
namespace = "file";
} else if (res.startsWith("http:")) {
namespace = "http";
} else if (res.startsWith("https:")) {
namespace = "https";
} else if (res.startsWith("npm:")) {
namespace = "npm";
} else if (res.startsWith("jsr:")) {
namespace = "jsr";
} else if (res.startsWith("data:")) {
namespace = "data";
}
const resolved = res.startsWith("file:") ? fromFileUrl(res) : res;
return {
path: resolved,
namespace,
};
}
build.onResolve({ filter: /.*/ }, onResolve);
build.onLoad(
{ filter: /.*/ },
async ({ path, pluginData, namespace, with: withAttr }) => {
// If the file is an entry point, return its content
if (pluginData?.entryPoint) {
return {
contents: pluginData.entryPoint.content,
loader: getLoader(path),
};
}
// If it's a file, check if it's already loaded by Lume
if (namespace === "file") {
const src = normalizePath(path, basePath);
const entry = site.fs.entries.get(src);
// Return the content loaded by Lume
if (entry) {
const { content } = await entry.getContent(textLoader);
return {
contents: content,
loader: getLoader(path),
};
}
}
// Load the module with the Deno loader
const url = namespace === "file"
? toFileUrl(path).toString()
: path;
// If the file is a JSON, force the module type to JSON
// this is needed because some npm packages import JSON files
// without the `with { type: "json" }` attribute
const moduleType = path.endsWith(".json")
? RequestedModuleType.Json
: getModuleType(withAttr);
// Load the file from the workspace's loader
const res = await loader.load(url, moduleType);
if (res.kind === "external") {
return null;
}
return {
contents: res.code,
loader: mediaToLoader(res.mediaType),
};
},
);
},
},
);
const { outputFiles, metafile, warnings, errors } = await build(
buildOptions,
);
await stop();
if (errors.length) {
log.error(`[esbuild plugin] ${errors.length} errors `);
}
if (warnings.length) {
log.warn(
`[esbuild plugin] ${warnings.length} warnings`,
);
}
return [outputFiles || [], metafile!, !!sourcemap];
}
site.process(
options.extensions,
async function processEsbuild(pages, allPages) {
const hasPages = warnUntil(
`[esbuild plugin] No ${
options.extensions.map((e) => e.slice(1).toUpperCase()).join(", ")
} files found. Use site.add() to add files. For example: site.add("script.js")`,
pages.length,
);
if (!hasPages) {
return;
}
const [outputFiles, metafile, enableSourceMap] = await runEsbuild(
pages,
);
const item = site.debugBar?.buildItem(
"[esbuild plugin] Build completed",
);
// Save the output code
for (const [outputPath, output] of Object.entries(metafile.outputs)) {
if (outputPath.endsWith(".map")) {
continue;
}
const normalizedOutPath = normalizePath(outputPath);
const outputFile = outputFiles.find((file) => {
const relativeFilePath = normalizePath(
normalizePath(file.path).replace(basePath, ""),
);
return relativeFilePath === normalizedOutPath;
});
if (!outputFile) {
log.error(
`[esbuild plugin] Could not match the metafile ${normalizedOutPath} to an output file.`,
);
continue;
}
// Replace .tsx and .jsx extensions with .js
const content = options.options.bundle
? outputFile.text
: resolveImports(
outputFile.text,
output.entryPoint
? relativePathFromUri(output.entryPoint, basePath)
: outputPath,
outputPath,
basePath,
metafile,
);
// Get the associated source map
const map = enableSourceMap
? outputFiles.find((f) => f.path === `${outputFile.path}.map`)
: undefined;
// Search the entry point of this output file
let entryPoint: Page | undefined;
if (output.entryPoint) {
const outputRelativeEntryPoint = relativePathFromUri(
output.entryPoint,
basePath,
);
entryPoint = pages.find((page) =>
page.sourcePath === outputRelativeEntryPoint ||
(page.sourcePath === "(generated)" &&
page.outputPath === outputRelativeEntryPoint)
);
}
// The page is a chunk
if (!entryPoint) {
const page = Page.create({ url: normalizedOutPath });
saveAsset(site, page, content, map?.text);
allPages.push(page);
continue;
}
if (item) {
item.items ??= [];
item.items.push({
title: normalizedOutPath,
details: bytes(outputFile.contents.length),
items: Object.entries(output.inputs)
.map(([title, { bytesInOutput }]) => ({
title,
details: bytes(bytesInOutput),
})),
});
}
// The page is an entry point
entryPoint.data.url = normalizedOutPath;
saveAsset(site, entryPoint, content, map?.text);
}
},
);
};
}
function relativePathFromUri(uri: string, basePath?: string): string {
if (uri.startsWith("deno:")) {
uri = uri.slice("deno:".length);
}
if (uri.startsWith("file://")) {
uri = fromFileUrl(uri);
}
return normalizePath(uri, basePath);
}
function resolveImports(
source: string,
sourcePath: string,
outputPath: string,
basePath: string,
metafile: Metafile,
): string {
source = source.replaceAll(
/\bfrom\s*["']([^"']+)["']/g,
(_, path) =>
`from "${
resolveImport(path, sourcePath, outputPath, basePath, metafile)
}"`,
);
source = source.replaceAll(
/\bimport\s*["']([^"']+)["']/g,
(_, path) =>
`import "${
resolveImport(path, sourcePath, outputPath, basePath, metafile)
}"`,
);
source = source.replaceAll(
/\bimport\([\s\n]*["']([^"']+)["'](?=[\s\n]*[,)])/g,
(_, path) =>
`import("${
resolveImport(path, sourcePath, outputPath, basePath, metafile)
}"`,
);
return source;
}
function resolveImport(
importPath: string,
sourcePath: string,
outputPath: string,
basePath: string,
metafile: Metafile,
): string {
if (importPath.startsWith(".") || importPath.startsWith("/")) {
const sourcePathOfImport = posix.join(
"/",
posix.dirname(sourcePath),
importPath,
);
const outputOfImport = Object.entries(metafile.outputs)
.find(([_, output]) => {
if (!output.entryPoint) {
return false;
}
const outputRelativeEntryPoint = relativePathFromUri(
output.entryPoint,
basePath,
);
return outputRelativeEntryPoint === sourcePathOfImport;
});
if (!outputOfImport) {
return importPath;
}
const outputPathOfImport = outputOfImport[0];
const relativeOutputPathOfImport = posix.relative(
posix.dirname(outputPath),
outputPathOfImport,
);
return "./" + relativeOutputPathOfImport;
}
return resolve(importPath, "").path;
}
function resolve(path: string, referrer: string) {
if (referrer && resolver) {
path = resolver(path, referrer) || path;
}
return {
path,
namespace: "deno",
};
}
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";
}
}
function mediaToLoader(type: MediaType): Loader {
switch (type) {
case MediaType.Jsx:
return "jsx";
case MediaType.JavaScript:
case MediaType.Mjs:
case MediaType.Cjs:
return "js";
case MediaType.TypeScript:
case MediaType.Mts:
case MediaType.Dmts:
case MediaType.Dcts:
return "ts";
case MediaType.Tsx:
return "tsx";
case MediaType.Css:
return "css";
case MediaType.Json:
return "json";
case MediaType.Html:
return "default";
case MediaType.Sql:
return "default";
case MediaType.Wasm:
return "binary";
case MediaType.SourceMap:
return "json";
case MediaType.Unknown:
return "default";
default:
return "default";
}
}
function getModuleType(withArgs: Record): RequestedModuleType {
switch (withArgs.type) {
case "text":
return RequestedModuleType.Text;
case "bytes":
return RequestedModuleType.Bytes;
case "json":
return RequestedModuleType.Json;
default:
return RequestedModuleType.Default;
}
}
function externalToRegex(external: string): RegExp {
return new RegExp(
"^" + external.replace(/[-/\\^$+?.()|[\]{}]/g, "\\$&").replace(
/\*/g,
".*",
) + "$",
);
}
export default esbuild;