// Build a single self-contained HTML file. // // Chrome and Firefox refuse ES module imports over file://, so the modular // source that is pleasant to develop cannot be the thing you hand someone. // This inlines the stylesheet and flattens the module graph into one script, // producing a file that plays by double-clicking it. // // It is a deliberately small bundler that understands only the module syntax // this project actually uses, and throws on anything it does not recognise — // a silent mis-bundle would be far worse than a failed build. import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { execFileSync } from 'node:child_process'; import { dirname, resolve, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const entryHtml = resolve(root, 'index.html'); const outDir = resolve(root, 'dist'); const outFile = resolve(outDir, 'theladder.html'); const id = (path) => relative(root, path).replaceAll('\\', '/'); /** Rewrite one module's source into a registry factory. */ function transform(source, path) { const exports = []; let body = source; // import { a, b as c } from './x.js' body = body.replace( /^[ \t]*import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"];?[ \t]*$/gm, (_m, names, from) => `const {${names.replaceAll(' as ', ': ')}} = __require(${JSON.stringify(id(resolve(dirname(path), from)))});`, ); // import * as ns from './x.js' body = body.replace( /^[ \t]*import\s*\*\s*as\s+(\w+)\s+from\s*['"]([^'"]+)['"];?[ \t]*$/gm, (_m, name, from) => `const ${name} = __require(${JSON.stringify(id(resolve(dirname(path), from)))});`, ); // import name from './x.js' body = body.replace( /^[ \t]*import\s+(\w+)\s+from\s*['"]([^'"]+)['"];?[ \t]*$/gm, (_m, name, from) => `const ${name} = __require(${JSON.stringify(id(resolve(dirname(path), from)))}).default;`, ); // export { a, b } (no `from` — re-exports are not used in this project) body = body.replace(/^[ \t]*export\s*\{([^}]*)\};?[ \t]*$/gm, (_m, names) => { for (const entry of names.split(',').map((n) => n.trim()).filter(Boolean)) { const [local, exported = local] = entry.split(/\s+as\s+/).map((n) => n.trim()); exports.push([exported, local]); } return ''; }); // export function / const / let / class body = body.replace(/^[ \t]*export\s+(async\s+)?(function|const|let|class)\s+(\w+)/gm, (_m, async_, kind, name) => { exports.push([name, name]); return `${async_ ?? ''}${kind} ${name}`; }); // export default body = body.replace(/^[ \t]*export\s+default\s+/gm, () => { exports.push(['default', '__default']); return 'const __default = '; }); const leftover = body.match(/^\s*(import|export)\s/m); if (leftover) { throw new Error(`${id(path)}: unsupported module syntax near "${leftover[0].trim()}"`); } const assignments = exports.map(([exported, local]) => ` __exports[${JSON.stringify(exported)}] = ${local};`).join('\n'); return `__define(${JSON.stringify(id(path))}, (__exports, __require) => {\n${body}\n${assignments}\n});`; } async function collect(entryPath, seen = new Map()) { if (seen.has(entryPath)) return seen; const source = await readFile(entryPath, 'utf8'); seen.set(entryPath, null); // reserve the slot before recursing const specifiers = [...source.matchAll(/^[ \t]*import\s.*?from\s*['"]([^'"]+)['"];?[ \t]*$/gm)] .map((match) => match[1]) .filter((specifier) => specifier.startsWith('.')); for (const specifier of specifiers) { await collect(resolve(dirname(entryPath), specifier), seen); } seen.set(entryPath, transform(source, entryPath)); return seen; } /** * The commit this build came from, marked `+` when the tree had uncommitted * changes. Substituted into the output only — src/build-info.js stays as it is * in the repository, so building never dirties the working tree. */ function buildIdentity() { try { const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, stdio: 'pipe' }) .toString().trim(); const dirty = execFileSync('git', ['status', '--porcelain'], { cwd: root, stdio: 'pipe' }) .toString().trim().length > 0; return dirty ? `${sha}+` : sha; } catch { return 'unknown'; } } async function build() { const html = await readFile(entryHtml, 'utf8'); const styleMatch = html.match(//); if (!styleMatch) throw new Error('index.html has no stylesheet link to inline'); const css = await readFile(resolve(root, styleMatch[1]), 'utf8'); const scriptMatch = html.match(/`); await mkdir(outDir, { recursive: true }); await writeFile(outFile, output, 'utf8'); const kb = (Buffer.byteLength(output) / 1024).toFixed(1); console.log(`built ${id(outFile)} — ${modules.size} modules, ${kb} kB, commit ${commit}`); } build().catch((error) => { console.error(`build failed: ${error.message}`); process.exitCode = 1; });