htmelt
Version:
Bundle your HTML assets with Esbuild and LightningCSS. Custom plugins, HMR platform, and more.
609 lines (602 loc) • 19.6 kB
JavaScript
import {
buildRelativeStyles,
findRelativeStyles
} from "./chunk-Q2H5B2YZ.mjs";
import {
updateRelatedWatcher
} from "./chunk-QYCXBBSD.mjs";
import {
buildEntryScripts,
compileSeparateEntry,
findRelativeScripts
} from "./chunk-SE5MUBQP.mjs";
import {
createDir,
relative,
setsEqual
} from "./chunk-SGZXFKQT.mjs";
// src/bundle.mts
import {
fileToId as fileToId2,
parseNamespace
} from "@htmelt/plugin";
import * as fs4 from "fs";
import glob2 from "glob";
import { cyan as cyan2, red, yellow as yellow2 } from "kleur/colors";
import * as path3 from "path";
import { performance } from "perf_hooks";
import { debounce } from "ts-debounce";
import { promisify as promisify2 } from "util";
// src/clientUtils.mts
import { appendChild, createScript, findElement } from "@htmelt/plugin";
import * as fs from "fs";
import * as path from "path";
var getConnectionFile = (config) => path.resolve(config.build, "_connection.mjs");
async function buildClientConnection(config) {
fs.mkdirSync(config.build, { recursive: true });
fs.writeFileSync(
getConnectionFile(config),
await compileSeparateEntry("./client/connection.mjs", config)
);
}
function injectClientConnection(document, outFile, config) {
const head = findElement(document.documentElement, (e) => e.tagName === "head");
const connectionFile = getConnectionFile(config);
if (document.hmr != false) {
appendChild(
head,
createScript({
src: relative(config.build, connectionFile).slice(1)
})
);
} else {
const stubFile = connectionFile.replace(/\.\w+$/, "_stub$&");
fs.writeFileSync(stubFile, "globalThis.htmelt = {export(){}}");
appendChild(
head,
createScript({
src: relative(config.build, stubFile).slice(1)
})
);
}
}
// src/copy.mts
import fs2 from "fs";
import glob from "glob";
import { cyan } from "kleur/colors";
import path2 from "path";
import { promisify } from "util";
async function copyFiles(patterns, config) {
let copied = 0;
for (let pattern of patterns) {
if (typeof pattern != "string") {
await Promise.all(
Object.entries(pattern).map(([srcPath, outPath]) => {
if (path2.isAbsolute(outPath)) {
return console.error(
`Failed to copy "${srcPath}" to "${outPath}": Output path must be relative`
);
}
if (outPath.startsWith("..")) {
return console.error(
`Failed to copy "${srcPath}" to "${outPath}": Output path must not be outside build directory`
);
}
outPath = path2.resolve(config.build, outPath);
createDir(outPath);
fs2.copyFileSync(srcPath, outPath);
copied++;
})
);
} else if (glob.hasMagic(pattern)) {
const matchedPaths = await promisify(glob)(pattern);
await Promise.all(
matchedPaths.map((srcPath) => {
const outPath = config.getBuildPath(srcPath);
createDir(outPath);
fs2.copyFileSync(srcPath, outPath);
copied++;
})
);
} else {
const srcPath = pattern;
const outPath = config.getBuildPath(srcPath);
createDir(outPath);
fs2.copyFileSync(pattern, outPath);
copied++;
}
}
if (copied) {
console.log(cyan("copied %s %s"), copied, copied == 1 ? "file" : "files");
}
}
// src/html.mts
import {
appendChild as appendChild2,
createElement,
fileToId,
findElement as findElement2,
parse,
parseFragment,
serialize,
setTextContent
} from "@htmelt/plugin";
import * as fs3 from "fs";
import { minify } from "html-minifier-terser";
import { yellow } from "kleur/colors";
import * as lightningCss from "lightningcss";
function parseHTML(html) {
const document = html.includes("<!DOCTYPE html>") || html.includes("<html") ? parse(html) : parseFragment(html);
if (!findElement2(document, (e) => e.tagName == "head")) {
const head = createElement("head");
appendChild2(document, head);
}
if (!findElement2(document, (e) => e.tagName == "body")) {
const body = createElement("body");
appendChild2(document, body);
}
return document;
}
async function buildHTML(document, config, flags) {
console.log(yellow("\u2301"), fileToId(document.file));
const outFile = config.getBuildPath(document.file);
try {
await buildRelativeStyles(document.styles, config, flags);
} catch (e) {
console.error(e);
return;
}
if (document.bundle.injectedStyles) {
const minifyResult = lightningCss.transform({
code: Buffer.from(document.bundle.injectedStyles.join("\n")),
filename: document.file + ".css",
minify: true
});
const css = minifyResult.code.toString();
const style = createElement("style");
setTextContent(style, css);
const head = findElement2(
document.documentElement,
(e) => e.tagName === "head"
);
appendChild2(head, style);
}
const buildSrcAttr = (ref) => {
let src = fileToId(ref.outPath);
if (!flags.watch) {
src = src.replace("/" + config.build + "/", config.base);
}
ref.srcAttr.value = src;
};
document.scripts.forEach(buildSrcAttr);
document.styles.forEach(buildSrcAttr);
for (const plugin of config.plugins) {
const hook = plugin.document;
if (hook) {
await hook(document);
}
}
if (flags.watch) {
injectClientConnection(document, outFile, config);
}
let html = serialize(document.documentElement);
if (flags.minify) {
try {
html = await minify(html, {
collapseWhitespace: true,
removeComments: true,
...config.htmlMinifierTerser
});
} catch (e) {
console.error(e);
}
}
createDir(outFile);
fs3.writeFileSync(outFile, html);
return html;
}
// src/bundle.mts
async function bundle(config, flags) {
if (flags.deletePrev ?? config.deletePrev) {
fs4.rmSync(config.build, { force: true, recursive: true });
}
flags.minify ??= config.mode != "development";
let server;
if (flags.watch) {
const { installHttpServer } = await import("./devServer-D3GVPBGV.mjs");
const servePlugins = config.plugins.filter((p) => p.serve);
server = await installHttpServer(config, servePlugins);
await buildClientConnection(config);
}
const createBuild = () => {
const bundles = /* @__PURE__ */ new Map();
const documents = {};
const scripts = {};
const loadDocument = (file) => {
const html = fs4.readFileSync(file, "utf8");
const documentElement = parseHTML(html);
const scripts2 = findRelativeScripts(documentElement, file, config);
const styles = findRelativeStyles(documentElement, file, config);
return { documentElement, scripts: scripts2, styles };
};
const buildScripts = async (bundle2) => {
const oldEntries = bundle2.entries;
const newEntries = new Set(bundle2.scripts);
const scripts2 = new Set(
bundle2.importers.flatMap((document) => {
for (const script of document.scripts) {
newEntries.add(script.srcPath);
}
return document.scripts;
})
);
let { context } = bundle2;
if (!context || !oldEntries || !setsEqual(oldEntries, newEntries)) {
context = await buildEntryScripts(
newEntries,
bundle2.scripts.size > 0 && ((entry) => bundle2.scripts.has(entry)),
config,
flags,
bundle2
);
bundle2.context = context;
bundle2.entries = newEntries;
}
const { metafile } = await context.rebuild();
bundle2.metafile = metafile;
bundle2.inputs = toBundleInputs(metafile);
if (!flags.watch) {
const outPaths = Object.keys(metafile.outputs).reduce(
(outPaths2, outPath) => {
const srcPath = metafile.outputs[outPath].entryPoint;
if (srcPath != null) {
outPaths2[path3.resolve(srcPath)] = path3.resolve(outPath);
}
return outPaths2;
},
{}
);
for (const script of scripts2) {
script.outPath = outPaths[script.srcPath];
}
}
return bundle2;
};
return {
documents,
/**
* Build state for standalone scripts added with the `scripts`
* config option. Exists only in `--watch` mode.
*/
get scripts() {
return scripts;
},
initialBuild: (async () => {
const seen = /* @__PURE__ */ new Set();
for (const entry of config.entries) {
let { file, bundleId = "default" } = entry;
file = path3.resolve(file);
const key = `${file}:${bundleId}`;
if (seen.has(key))
continue;
seen.add(key);
let bundle2 = bundles.get(bundleId);
if (!bundle2) {
bundle2 = {
id: bundleId,
hmr: true,
scripts: /* @__PURE__ */ new Set(),
importers: []
};
bundles.set(bundleId, bundle2);
}
if (entry.hmr == false) {
bundle2.hmr = false;
}
if (file.endsWith(".html")) {
const document = {
...entry,
...loadDocument(file),
file,
bundle: bundle2
};
const id = fileToId2(file);
documents[id] = document;
bundle2.importers.push(document);
} else if (/\.[mc]?[tj]sx?$/.test(file)) {
bundle2.scripts.add(file);
} else {
console.warn(red("\u26A0"), "unsupported entry type:", file);
}
}
let isolatedScripts;
if (config.scripts) {
const matches = await Promise.all(
config.scripts.map((p) => promisify2(glob2)(p))
);
isolatedScripts = Array.from(
new Set(matches.flat()),
(p) => path3.resolve(p)
);
} else {
isolatedScripts = [];
}
const bundlePromises = {};
const bundlesPromise = Promise.all(
Array.from(bundles, async ([bundleId, bundle2]) => {
await (bundlePromises[bundleId] = buildScripts(bundle2));
if (config.relatedWatcher) {
updateRelatedWatcher(config.relatedWatcher, bundle2.metafile);
}
return [bundleId, bundle2];
})
).then(Object.fromEntries);
await Promise.all([
bundlesPromise.then(async (bundles2) => {
config.bundles = bundles2;
await Promise.all(
config.plugins.map((plugin) => plugin.bundles?.(bundles2))
);
}),
...Object.values(documents).map(
(document) => bundlePromises[document.bundle.id].then(
() => buildHTML(document, config, flags)
)
),
...isolatedScripts.map((srcPath) => {
console.log(yellow2("\u2301"), fileToId2(srcPath));
if (flags.watch) {
return compileSeparateEntry(srcPath, config, {
metafile: true,
watch: true
}).then(({ outputFiles, context, metafile }) => {
const inputs = toBundleInputs(metafile, config.watcher);
const outPath = config.getBuildPath(srcPath);
scripts[srcPath] = {
srcPath,
outPath,
context,
metafile,
inputs
};
createDir(outPath);
fs4.writeFileSync(outPath, outputFiles[0].text);
updateRelatedWatcher(config.relatedWatcher, metafile);
});
}
return compileSeparateEntry(srcPath, config).then((code) => {
const outFile = config.getBuildPath(srcPath);
createDir(outFile);
fs4.writeFileSync(outFile, code);
});
})
]);
if (config.copy) {
await copyFiles(config.copy, config);
}
for (const plugin of config.plugins) {
if (plugin.initialBuild) {
await plugin.initialBuild();
}
}
})(),
async rebuildHTML(uri) {
const document = documents[uri];
if (!document) {
return;
}
const file = uri.startsWith("/@fs/") ? uri.slice(4) : path3.join(process.cwd(), uri);
const oldScripts = document.scripts;
const oldMetafile = document.bundle.metafile;
Object.assign(document, loadDocument(file));
await Promise.all([
buildHTML(document, config, flags),
(oldScripts.length !== document.scripts.length || oldScripts.some(
(script, i) => script.srcPath !== document.scripts[i].srcPath
)) && buildScripts(document.bundle).then((bundle2) => {
updateRelatedWatcher(
config.relatedWatcher,
bundle2.metafile,
oldMetafile
);
})
]);
},
async rebuildStyles() {
await Promise.all(
Object.values(documents).map(
(document) => buildRelativeStyles(document.styles, config, flags)
)
);
},
dispose() {
for (const bundle2 of bundles.values()) {
bundle2.context?.dispose();
}
for (const script of Object.values(scripts)) {
script.context.dispose();
}
server?.close();
config.watcher?.close();
}
};
};
const timer = performance.now();
const build = createBuild();
await (config.lastBuild = build.initialBuild);
console.log(
cyan2("build complete in %sms"),
(performance.now() - timer).toFixed(2)
);
if (server) {
const { installWebSocketServer } = await import("./devServer-D3GVPBGV.mjs");
const hmrInstances = [];
const clients = installWebSocketServer(server, config, hmrInstances);
const watcher = config.watcher;
const changedScripts = /* @__PURE__ */ new Set();
const changedModules = /* @__PURE__ */ new Set();
const changedPages = /* @__PURE__ */ new Set();
config.relatedWatcher?.onChange((relatedFile) => {
if (parseNamespace(relatedFile)) {
watcher.emit("change", relatedFile);
} else {
fs4.utimesSync(relatedFile, /* @__PURE__ */ new Date(), /* @__PURE__ */ new Date());
}
});
watcher.on("change", (file) => {
const namespace = parseNamespace(file);
const id = namespace ? file : fileToId2(path3.resolve(file));
if (id.endsWith(".html")) {
console.log(cyan2("\u21BA"), id);
changedPages.add(id);
requestRebuild();
} else {
let isFullReload = false;
for (const script of Object.values(build.scripts)) {
if (script.inputs.includes(id)) {
changedScripts.add(script);
isFullReload = true;
}
}
if (isFullReload) {
requestRebuild();
} else if (id.endsWith(".css") || config.modules.has(id)) {
changedModules.add(id);
requestRebuild();
}
}
});
watcher.on("unlink", (file) => {
const namespace = parseNamespace(file);
config.relatedWatcher?.forgetRelatedFile(
namespace ? file : path3.resolve(file)
);
if (path3.isAbsolute(file)) {
return;
}
const outPath = config.getBuildPath(file).replace(/\.[jt]sx?$/, ".js");
try {
fs4.rmSync(outPath);
let outDir = path3.dirname(outPath);
while (outDir !== config.build) {
const stats = fs4.readdirSync(outDir);
if (stats.length)
break;
fs4.rmSync(outDir);
outDir = path3.dirname(outDir);
}
console.log(red("\u2013"), file);
} catch {
}
});
const requestRebuild = debounce(() => {
config.lastBuild = rebuild();
}, 200);
const rebuild = async () => {
let isFullReload = changedPages.size > 0 || changedScripts.size > 0;
let stylesChanged = false;
const acceptedFiles = /* @__PURE__ */ new Map();
if (!isFullReload) {
const fullReloadFiles = /* @__PURE__ */ new Set();
for (const bundle2 of Object.values(config.bundles)) {
if (!bundle2.hmr) {
for (const file of bundle2.inputs) {
fullReloadFiles.add(file);
}
}
}
accept:
for (let file of changedModules) {
console.log(cyan2("\u21BA"), file);
if (file.endsWith(".css")) {
stylesChanged = true;
}
if (fullReloadFiles.has(file)) {
isFullReload = true;
break;
}
for (const hmr of hmrInstances) {
if (hmr.accept(file)) {
let files = acceptedFiles.get(hmr);
if (!files) {
acceptedFiles.set(hmr, files = []);
}
console.log("HMR accepted file:", file);
files.push(file);
continue accept;
}
}
isFullReload = true;
break;
}
if (isFullReload) {
acceptedFiles.clear();
}
}
const errors = [];
const htmlRebuildPromises = Array.from(
changedPages,
(file) => build.rebuildHTML(file).catch((error) => {
errors.push(error);
})
);
const scriptRebuildPromises = Array.from(changedScripts, (script) => {
return script.context.rebuild().then(({ outputFiles, metafile }) => {
fs4.writeFileSync(script.outPath, outputFiles[0].text);
script.metafile = metafile;
script.inputs = toBundleInputs(metafile);
}).catch((error) => {
errors.push(error);
});
});
changedScripts.clear();
changedModules.clear();
changedPages.clear();
await Promise.all([
Promise.all(
Array.from(acceptedFiles, async ([hmr, files]) => hmr.update(files))
).catch((error) => {
errors.push(error);
}),
...htmlRebuildPromises,
...scriptRebuildPromises,
// Rebuild all styles if a .css file is changed at the same time that a
// full reload was triggered, since the .css file may be imported by a
// page/script that changed.
isFullReload && stylesChanged && build.rebuildStyles().catch((error) => {
errors.push(error);
})
]);
if (errors.length) {
const seen = /* @__PURE__ */ new Set();
for (const error of errors) {
if (seen.has(error.message))
continue;
seen.add(error.message);
console.error();
console.error(error);
}
console.error();
} else if (isFullReload) {
await Promise.all(config.plugins.map((plugin) => plugin.fullReload?.()));
await Promise.all(Array.from(clients, (client) => client.reload()));
}
console.log(yellow2("watching files..."));
};
console.log(yellow2("watching files..."));
}
return build;
}
function toBundleInputs(metafile, watcher) {
return Object.keys(metafile.inputs).map((file) => {
if (file.includes(":")) {
return file;
}
watcher?.add(file);
return fileToId2(file);
});
}
export {
bundle,
toBundleInputs
};