UNPKG

htmelt

Version:

Bundle your HTML assets with Esbuild and LightningCSS. Custom plugins, HMR platform, and more.

609 lines (602 loc) 19.6 kB
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 };