Files
TheLadder/tools/build.js
T
JesseMarkowitzandClaude Opus 5 7f20be83e8 Add The Frontier, a second setting, and a picker to choose between them
Cold Fork is a freight yard: 29 events across two stages, a four-day cycle, and
its own relationship and skill vocabulary. src/engine/ was not touched. Adding it
needed three changes outside the engine: one save slot per pack, app.js holding
the current pack, and turn.js no longer naming the mailroom (DECISIONS #34).
build.js now handles imports wrapped across lines. A conformance suite runs over
every registered pack. DECISIONS #35 records three calls on open questions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
2026-09-12 21:03:50 -04:00

176 lines
6.9 KiB
JavaScript

// 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 <expression>
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
// `[\s\S]*?` rather than `.*?`: a destructured import may be wrapped across
// several lines, and `transform` below already rewrites those — a scan that
// missed them produced a bundle with a __require for a module that was never
// collected, which only failed when the bundled game was run.
const specifiers = [...source.matchAll(/^[ \t]*import\s[\s\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(/<link\s+rel="stylesheet"\s+href="([^"]+)"\s*\/?>/);
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(/<script type="module">([\s\S]*?)<\/script>/);
if (!scriptMatch) throw new Error('index.html has no module script');
const bootstrapSource = scriptMatch[1];
const entrySpecifier = bootstrapSource.match(/from\s*['"]([^'"]+)['"]/)?.[1];
if (!entrySpecifier) throw new Error('could not find the entry module in index.html');
const entryPath = resolve(root, entrySpecifier);
const modules = await collect(entryPath);
const bootstrap = transform(bootstrapSource, entryHtml).replace(
/__define\("index\.html"/, '__define("__bootstrap__"',
);
const script = [
'(() => {',
' "use strict";',
' const __factories = new Map();',
' const __cache = new Map();',
' const __define = (id, factory) => __factories.set(id, factory);',
' const __require = (id) => {',
' if (__cache.has(id)) return __cache.get(id);',
' const factory = __factories.get(id);',
' if (!factory) throw new Error(`module not bundled: ${id}`);',
' const exports = {};',
' __cache.set(id, exports);',
' factory(exports, __require);',
' return exports;',
' };',
...[...modules.values()].map((code) => code.replace(/^/gm, ' ')),
bootstrap.replace(/^/gm, ' '),
' __require("__bootstrap__");',
'})();',
].join('\n');
const commit = buildIdentity();
const stamped = script
.replace("commit: 'dev'", `commit: '${commit}'`)
.replace('builtAt: null', `builtAt: '${new Date().toISOString().slice(0, 10)}'`);
if (stamped === script) throw new Error('could not stamp the build identity into the bundle');
const output = html
.replace(styleMatch[0], `<style>\n${css}\n </style>`)
.replace(scriptMatch[0], `<script>\n${stamped}\n </script>`);
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;
});