/**
* Static-site builder for Cloudflare Pages (and any plain static host).
*
* Reuses the same view functions as the dev server in web/server.ts —
* only the URL pattern and the homepage interactive block change
* (mode: "static"). Produces a self-contained `dist/` tree:
*
* dist/
* index.html # landing + run list
* run/<run-id>/index.html # one folder per past run
* code/index.html # curated entry points + tree
* code/<file-path>/index.html # one folder per browsable file
* static/ # css, client js (form bails)
* _redirects # Cloudflare Pages SPA fallback
*
* npm run build:static
*/
import { mkdir, writeFile, rm, readFile, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { homepage, runDetail, codePage, notFoundPage } from '../web/views.js';
import { listRuns, loadRunById } from '../web/runs.js';
import { buildCodeTree, readCodeFile, type TreeNode } from '../web/code-tree.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const DIST = path.join(ROOT, 'dist');
const STATIC_SRC = path.join(ROOT, 'web', 'static');
const STATIC_DEST = path.join(DIST, 'static');
const RENDER_STATIC = { mode: 'static' as const };
async function copyDir(src: string, dest: string): Promise<void> {
await mkdir(dest, { recursive: true });
const entries = await readdir(src, { withFileTypes: true });
for (const e of entries) {
const s = path.join(src, e.name);
const d = path.join(dest, e.name);
if (e.isDirectory()) await copyDir(s, d);
else if (e.isFile()) {
const buf = await readFile(s);
await writeFile(d, buf);
}
}
}
function collectFiles(nodes: readonly TreeNode[]): string[] {
const out: string[] = [];
const walk = (ns: readonly TreeNode[]): void => {
for (const n of ns) {
if (n.kind === 'file') out.push(n.path);
else if (n.children) walk(n.children);
}
};
walk(nodes);
return out;
}
async function writePage(dir: string, html: string): Promise<void> {
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, 'index.html'), html, 'utf8');
}
async function main(): Promise<void> {
const t0 = Date.now();
console.log(`Building static site to ${path.relative(process.cwd(), DIST)}/`);
/* 1. Clean. */
if (existsSync(DIST)) await rm(DIST, { recursive: true });
await mkdir(DIST, { recursive: true });
/* 2. Static assets (CSS, app.js — the latter no-ops on static pages
* because the form element does not exist, but we keep it so the
* layout HTML works identically across modes). */
await copyDir(STATIC_SRC, STATIC_DEST);
/* 3. Cloudflare Pages 404 fallback. _redirects gives us a friendly
* not-found page for any URL the static tree doesn't cover. */
await writeFile(
path.join(DIST, '_redirects'),
'/* /404.html 404\n',
'utf8',
);
await writeFile(
path.join(DIST, '404.html'),
notFoundPage('That page is not part of this static deployment.', RENDER_STATIC),
'utf8',
);
/* 4. Homepage + run-list. */
const runs = await listRuns();
await writeFile(path.join(DIST, 'index.html'), homepage(runs, RENDER_STATIC), 'utf8');
console.log(` ✓ index.html (${runs.length} run(s) listed)`);
/* 5. One folder per run. */
let runPages = 0;
for (const r of runs) {
const run = await loadRunById(r.id);
if (!run) continue;
await writePage(path.join(DIST, 'run', r.id), runDetail(run, RENDER_STATIC));
runPages++;
}
console.log(` ✓ run/<id>/index.html × ${runPages}`);
/* 6. Code browser — landing page + one folder per browsable file.
* The allow-list lives in web/code-tree.ts; the static build emits
* a page for every file the tree shows, so the sidebar navigates
* fully. The curated shortcuts on the landing page are highlighted
* entry points, not the limit. */
const tree = await buildCodeTree(ROOT);
await writePage(path.join(DIST, 'code'), codePage(tree, null, RENDER_STATIC));
const files = collectFiles(tree);
let codePages = 0;
for (const filePath of files) {
const f = await readCodeFile(ROOT, filePath);
if (!f) continue;
await writePage(path.join(DIST, 'code', filePath), codePage(tree, f, RENDER_STATIC));
codePages++;
}
console.log(` ✓ code/index.html + code/<path>/index.html × ${codePages}`);
/* 7. Stamp build metadata so Cloudflare Pages deploys are
* distinguishable. */
const meta = {
builtAt: new Date().toISOString(),
runs: runPages,
codePages,
totalPages: 1 + runPages + 1 + codePages + 1 /* 404 */,
};
await writeFile(path.join(DIST, 'build-meta.json'), JSON.stringify(meta, null, 2), 'utf8');
const elapsed = ((Date.now() - t0) / 1000).toFixed(2);
console.log(`Done. ${meta.totalPages} HTML page(s) in ${elapsed}s.`);
console.log(`Deploy: point Cloudflare Pages "Build output directory" at bid-poc/dist`);
}
main().catch(err => {
console.error('Static build failed:', err);
process.exit(1);
});