BID · Console
Baseline · Intelligence · Decision
scripts/build-static.ts 5,242 bytes · typescript
/**
 * 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);
});