The stamping scheme was wrong in a way worth recording. tools/stamp.js rewrote
src/build-info.js before every serve and build, so after each commit the
committed stamp named the *previous* commit — as it does right now, reading
76072ed+ while HEAD is 5c1784d — and the next serve rewrote it and dirtied the
tree again. A generated value does not belong in a tracked file if anything
routinely regenerates it.
src/build-info.js is now permanent and reads `commit: 'dev'`. tools/build.js
substitutes the real commit into the bundled output only, and fails loudly if
the substitution finds nothing to replace. So dist/theladder.html names the
commit that produced it, running from source honestly reads "0.3.0 · dev", and
the working tree never churns. tools/stamp.js is gone and `npm run serve` is a
plain static server again.
Also here, for picking this up later: a "Where things stand" section in the
README with the five open questions in the order they are likely to matter —
whether money stops mattering late, the negotiation gate that four early options
sit behind, the feedback widget nobody uses, how thin Dispatch is next to the
mailroom, and third-tier versus second-pack.
121 tests. docs/DECISIONS.md §25 corrected to describe what the code now does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
172 lines
6.6 KiB
JavaScript
172 lines
6.6 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
|
|
|
|
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(/<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;
|
|
});
|