wordle-five: the engine, the reward, the solver and the probe that checks them

The Python is the source of truth; src/demos/wordle/engine.ts will be a port of
it, and CI gates the two against a SHA-256 over all 21.2M (guess, answer)
pattern pairs rather than a hand-picked vector file — a vector file only ever
catches the cases somebody thought of.

The reward is three weighted components, and the third one is the reason this
demo is worth building. `solved` and `economy` pull toward winning. `consistency`
pulls against them, because a player maximising information deliberately guesses
words that cannot win — a word that splits the remaining candidates evenly
teaches more than a word that might happen to be right. That is good play, and
it costs consistency.

The probe ladder proves the tension is real rather than asserted:

  inaction        0.0000   crude       0.0111   plausible  0.1224
  candidate_only  0.8925   exhaustive  0.9031   oracle     0.9458

The two good policies are 0.05 apart and neither dominates — the entropy oracle
takes 1.00 economy and 0.73 consistency, the candidate-only player takes 0.75
and 1.00. Which one wins is a decision about what you want, which is the whole
argument the site exists to make. probe.py fails CI if either starts dominating.

Two traps found by building it. `consistency` is scored over turns SPENT, not
guesses accepted: counting only legal guesses hands a free 1.0 to a policy that
plays one word and then jams the parser five times — one guess, no
contradictions, perfect score. And `economy`'s denominator is the depth the
SHIPPED solver reaches, not a depth-optimal search: entropy-greedy is not
depth-optimal, so grading it against an exact optimum would make the oracle
rung fail its own assertion on some seeds.

The word lists are built from Wordnik (MIT) intersected with SCOWL, never from
the original game's 2,315 answers. 4,603 answers makes this materially harder
than the original, so the published SALET/3.4212 results are cited as belonging
to that list and our own reference player's TARES/3.72 is measured here.

verifiers is an optional extra. The engine, reward, solver and probe all run —
and gate — without an RL stack resolvable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
karti-ai
2026-08-28 15:39:03 -07:00
parent 5a9ff8dda9
commit a56f097f28
54 changed files with 8201 additions and 0 deletions
+718
View File
@@ -0,0 +1,718 @@
/**
* Shared plumbing for the contract scripts.
*
* Every `scripts/check-*.mjs` is a gate, not a linter: it exits non-zero with a
* message naming the file and the numbered rule that was broken, so a failure
* in CI reads as an instruction rather than a puzzle.
*
* The demo contract lives in TypeScript and these scripts are plain Node, so
* most of what follows is a small, deliberately dumb TypeScript reader: find a
* named declaration, brace-match its object/array literal, strip the two TS-only
* forms a literal can legally carry (`as const`, `satisfies X`), and evaluate it
* in a bare `vm` context.
*
* That reader is honest about its limits. A literal that references an imported
* constant does not evaluate, and the scripts FAIL rather than skip: the whole
* point of `meta` and `reward.components` is that a human can read the numbers
* off the page beside the code, so a weight hidden behind an indirection is a
* contract problem, not a tooling problem.
*/
import fs from 'node:fs';
import path from 'node:path';
import http from 'node:http';
import zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export const abs = (...parts) => path.join(ROOT, ...parts);
export const rel = (p) => path.relative(ROOT, p) || '.';
export const exists = (p) => fs.existsSync(p);
export const read = (p) => fs.readFileSync(p, 'utf8');
export const readJson = (p) => JSON.parse(read(p));
/* ------------------------------------------------------------------ output */
const ESC = '\u001b[';
const COLOUR = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
const paint = (code, s) => (COLOUR ? `${ESC}${code}m${s}${ESC}0m` : String(s));
export const bold = (s) => paint('1', s);
export const dim = (s) => paint('2', s);
export const red = (s) => paint('31', s);
export const green = (s) => paint('32', s);
export const yellow = (s) => paint('33', s);
export const cyan = (s) => paint('36', s);
/**
* Collects failures instead of throwing on the first one.
*
* A contract check that dies on failure #1 makes the author fix and re-run
* seven times. Every rule that can still be evaluated is evaluated.
*/
export class Report {
/** @param {string} title */
constructor(title) {
this.title = title;
/** @type {{file: string, rule: string, message: string}[]} */
this.failures = [];
/** @type {string[]} */
this.warnings = [];
/** @type {string[]} */
this.staticNotes = [];
this.passed = 0;
}
/**
* @param {string} file repo-relative path the reader should open
* @param {string} rule the numbered rule, e.g. 'rule 10 (reward weights)'
* @param {string} message what is actually wrong
*/
fail(file, rule, message) {
this.failures.push({ file, rule, message });
return false;
}
/** Assert, recording either a pass or a precise failure. */
check(condition, file, rule, message) {
if (condition) {
this.passed += 1;
return true;
}
return this.fail(file, rule, message);
}
warn(message) {
this.warnings.push(message);
}
/** Record that a rule was verified by reading source, not by running it. */
staticOnly(message) {
this.staticNotes.push(message);
}
/** Prints the report and exits the process. Never returns. */
finish() {
const out = [];
if (this.staticNotes.length) {
out.push('');
out.push(dim('Checked by reading source, not by executing it:'));
for (const n of this.staticNotes) out.push(dim(` - ${n}`));
}
if (this.warnings.length) {
out.push('');
for (const w of this.warnings) out.push(`${yellow('warn')} ${w}`);
}
if (this.failures.length) {
out.push('');
for (const f of this.failures) {
out.push(`${red('FAIL')} ${bold(f.file)}`);
out.push(` ${cyan(f.rule)}: ${f.message}`);
}
out.push('');
out.push(red(`${this.title}: ${this.failures.length} failure(s), ${this.passed} check(s) passed.`));
console.error(out.join('\n'));
process.exit(1);
}
out.push('');
out.push(green(`${this.title}: ${this.passed} check(s) passed.`));
console.log(out.join('\n'));
process.exit(0);
}
}
/** Fatal error that is the script's own problem, not the repo's. */
export function die(message) {
console.error(`${red('FAIL')} ${message}`);
process.exit(1);
}
/* ------------------------------------------------------- filesystem walking */
/**
* @param {string} dir
* @param {(file: string) => boolean} [filter]
* @returns {string[]} absolute paths, depth-first, stable order
*/
export function walk(dir, filter = () => true) {
if (!exists(dir)) return [];
/** @type {string[]} */
const out = [];
const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1));
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === '__pycache__') continue;
out.push(...walk(full, filter));
} else if (entry.isFile() && filter(full)) {
out.push(full);
}
}
return out;
}
export const DEMOS_DIR = abs('src', 'demos');
/** Every directory under `src/demos`, including `_template`. */
export function allDemoDirs() {
if (!exists(DEMOS_DIR)) return [];
return fs
.readdirSync(DEMOS_DIR, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
}
/** Shippable demos: a leading underscore marks scaffolding, not a demo. */
export function demoSlugs() {
return allDemoDirs().filter((name) => !name.startsWith('_'));
}
/* ----------------------------------------------------- the small TS reader */
/**
* Splits source into code and non-code (string literal / comment) spans.
*
* Everything below works on this rather than on raw regex, because the metas
* are full of prose: a `description` containing the words "as an executive"
* is otherwise indistinguishable from a TypeScript `as` assertion.
*
* @param {string} src
* @returns {{code: boolean, text: string}[]}
*/
export function segment(src) {
/** @type {{code: boolean, text: string}[]} */
const out = [];
let codeStart = 0;
let i = 0;
const flushCode = (end) => {
if (end > codeStart) out.push({ code: true, text: src.slice(codeStart, end) });
};
while (i < src.length) {
const c = src[i];
const next = src[i + 1];
if (c === '/' && next === '/') {
flushCode(i);
const nl = src.indexOf('\n', i);
const end = nl === -1 ? src.length : nl;
out.push({ code: false, text: src.slice(i, end) });
i = codeStart = end;
continue;
}
if (c === '/' && next === '*') {
flushCode(i);
const close = src.indexOf('*/', i + 2);
const end = close === -1 ? src.length : close + 2;
out.push({ code: false, text: src.slice(i, end) });
i = codeStart = end;
continue;
}
if (c === "'" || c === '"' || c === '`') {
flushCode(i);
const end = endOfString(src, i) + 1;
out.push({ code: false, text: src.slice(i, end) });
i = codeStart = end;
continue;
}
i += 1;
}
flushCode(src.length);
return out;
}
/** Index of the closing quote of the string literal starting at `start`. */
function endOfString(src, start) {
const quote = src[start];
for (let j = start + 1; j < src.length; j += 1) {
const c = src[j];
if (c === '\\') {
j += 1;
continue;
}
if (quote === '`' && c === '$' && src[j + 1] === '{') {
j = scanBalanced(src, j + 1) - 1;
continue;
}
if (c === quote) return j;
}
throw new Error(`unterminated string literal starting at offset ${start}`);
}
/**
* End offset (exclusive) of the bracket pair opening at `start`.
* Skips strings and comments, and recurses through `${...}` in templates.
*/
export function scanBalanced(src, start) {
const open = src[start];
const close = open === '{' ? '}' : open === '[' ? ']' : open === '(' ? ')' : null;
if (!close) throw new Error(`offset ${start} is ${JSON.stringify(open)}, not an opening bracket`);
let depth = 0;
for (let i = start; i < src.length; i += 1) {
const c = src[i];
const next = src[i + 1];
if (c === '/' && next === '/') {
const nl = src.indexOf('\n', i);
if (nl === -1) break;
i = nl;
continue;
}
if (c === '/' && next === '*') {
const end = src.indexOf('*/', i + 2);
i = end === -1 ? src.length : end + 1;
continue;
}
if (c === "'" || c === '"' || c === '`') {
i = endOfString(src, i);
continue;
}
if (c === open) depth += 1;
else if (c === close) {
depth -= 1;
if (depth === 0) return i + 1;
}
}
throw new Error(`unbalanced ${open} starting at offset ${start}`);
}
/** Offsets of every code (non-string, non-comment) span in `src`. */
function codeSpans(src) {
const spans = [];
let at = 0;
for (const s of segment(src)) {
spans.push({ code: s.code, start: at, end: at + s.text.length });
at += s.text.length;
}
return spans;
}
/**
* Finds the literal that follows `anchor` and returns its source text.
*
* @param {string} src
* @param {RegExp} anchor must match immediately before the opening bracket
* @param {'{' | '['} open
* @returns {string | null}
*/
export function literalAfter(src, anchor, open = '{') {
const flags = anchor.flags.includes('g') ? anchor.flags : `${anchor.flags}g`;
const re = new RegExp(anchor.source, flags);
const spans = codeSpans(src);
const isCode = (offset) => {
const hit = spans.find((s) => offset >= s.start && offset < s.end);
return hit ? hit.code : true;
};
let match;
while ((match = re.exec(src)) !== null) {
// The anchor may legally match inside a comment or a string — a docblock
// that quotes `const meta = {`. Only a match in real code counts.
if (!isCode(match.index)) continue;
const from = src.indexOf(open, match.index + Math.max(match[0].length - 1, 0));
if (from === -1) continue;
const between = src.slice(match.index + match[0].length, from);
if (/[;=}]/.test(between)) continue;
return src.slice(from, scanBalanced(src, from));
}
return null;
}
/**
* Evaluates a TypeScript object/array literal as plain data.
*
* `as const` and `satisfies T` are stripped from code spans only. Anything else
* a literal might carry — an identifier, a call, a spread of an import — throws,
* and callers turn that into a contract failure with the file named.
*
* @param {string} text
* @param {string} label
*/
export function evalLiteral(text, label) {
const js = segment(text)
.map((s) =>
s.code
? s.text
.replace(/\bas\s+const\b/g, '')
.replace(/\bsatisfies\s+[A-Za-z_$][\w$.]*(?:<[^>]*>)?(?:\[\])*/g, '')
.replace(/\bas\s+[A-Za-z_$][\w$.]*(?:<[^>]*>)?(?:\[\])*/g, '')
: s.text,
)
.join('');
try {
const value = vm.runInNewContext(`(${js})`, Object.create(null), { timeout: 2000 });
return { ok: true, value, error: null };
} catch (error) {
return {
ok: false,
value: null,
error:
`the ${label} literal did not evaluate as plain data (${error.message}). ` +
'These literals are read by tooling and by humans reading the page beside the code, ' +
'so they must be written out, not assembled from imported constants.',
};
}
}
/** Members of a string-union type alias, e.g. `export type Vertical = 'a' | 'b'`. */
export function parseStringUnion(src, typeName) {
const match = src.match(new RegExp(`\\btype\\s+${typeName}\\s*=([^;]+);`));
if (!match || !match[1]) return null;
const members = [...match[1].matchAll(/'([^']+)'/g)].map((m) => m[1]);
return members.length ? members : null;
}
/* ------------------------------------------------------------ demo metadata */
const META_ANCHORS = [
/(?:export\s+)?const\s+meta\s*(?::\s*[^=]+)?=\s*/,
/(?:export\s+)?const\s+[A-Za-z_$][\w$]*Meta\s*(?::\s*[^=]+)?=\s*/,
/\bmeta\s*:\s*/,
];
const CANDIDATE_META_FILES = ['meta.ts', 'meta.tsx', 'demo.tsx', 'demo.ts', 'index.ts', 'index.tsx'];
/**
* Reads one demo's `DemoMeta` out of its source.
* @param {string} slug
* @returns {{file: string, meta: Record<string, any>} | {file: string, error: string}}
*/
export function loadMeta(slug) {
const dir = path.join(DEMOS_DIR, slug);
const tried = [];
for (const name of CANDIDATE_META_FILES) {
const file = path.join(dir, name);
if (!exists(file)) continue;
tried.push(rel(file));
const src = read(file);
for (const anchor of META_ANCHORS) {
let text;
try {
text = literalAfter(src, anchor, '{');
} catch (error) {
return { file: rel(file), error: `could not brace-match the meta literal: ${error.message}` };
}
if (!text) continue;
const result = evalLiteral(text, 'DemoMeta');
if (!result.ok) return { file: rel(file), error: result.error };
if (result.value && typeof result.value === 'object' && 'slug' in result.value) {
return { file: rel(file), meta: result.value };
}
}
}
return {
file: tried[0] ?? rel(path.join(dir, 'meta.ts')),
error:
tried.length === 0
? `no meta source found; expected one of ${CANDIDATE_META_FILES.join(', ')} in ${rel(dir)}`
: `no DemoMeta object literal with a \`slug\` key found in ${tried.join(', ')}`,
};
}
/** Every demo whose meta could be read, keyed by directory name. */
export function loadAllMetas() {
/** @type {Map<string, Record<string, any>>} */
const metas = new Map();
/** @type {{slug: string, file: string, error: string}[]} */
const errors = [];
for (const slug of demoSlugs()) {
const result = loadMeta(slug);
if ('meta' in result) metas.set(slug, result.meta);
else errors.push({ slug, file: result.file, error: result.error });
}
return { metas, errors };
}
/* ------------------------------------------------------------------ verticals */
export const VERTICALS_FILE = abs('src', 'content', 'verticals.ts');
const VERTICAL_ANCHORS = [
/(?:export\s+)?const\s+VERTICALS\s*(?::\s*[^=]+)?=\s*/,
/(?:export\s+)?const\s+verticals\s*(?::\s*[^=]+)?=\s*/,
];
const asVertical = (id, v) => ({
id,
title: v?.title ?? v?.label ?? v?.name ?? null,
description: v?.description ?? v?.blurb ?? v?.tagline ?? v?.summary ?? null,
});
/**
* The vertical records the site groups demos by.
*
* Tolerant about field names on purpose: this file belongs to another lane, and
* a prerender that hard-codes `label` then silently bakes an empty <title> when
* the author wrote `name` is worse than one that looks for both.
*/
export function loadVerticals() {
if (!exists(VERTICALS_FILE)) return { list: [], error: `${rel(VERTICALS_FILE)} does not exist` };
const src = read(VERTICALS_FILE);
for (const anchor of VERTICAL_ANCHORS) {
for (const open of ['[', '{']) {
let text = null;
try {
text = literalAfter(src, anchor, open);
} catch {
text = null;
}
if (!text) continue;
const result = evalLiteral(text, 'verticals');
if (!result.ok) return { list: [], error: result.error };
const value = result.value;
const list = Array.isArray(value)
? value.map((v) => asVertical(v?.id ?? v?.slug ?? v?.key, v))
: value && typeof value === 'object'
? Object.entries(value).map(([id, v]) => asVertical(id, v))
: [];
const clean = list.filter((v) => typeof v.id === 'string' && v.id.length > 0);
if (clean.length) return { list: clean, error: null };
}
}
return { list: [], error: `no \`verticals\` or \`VERTICALS\` literal found in ${rel(VERTICALS_FILE)}` };
}
/* --------------------------------------------------------------------- traces */
export const MANIFEST_FILE = abs('public', 'traces', 'manifest.json');
/**
* Normalises `public/traces/manifest.json` into `slug -> {runs, extras}`.
*
* Four shapes are accepted because the manifest is authored by hand and all
* four are things a reasonable person writes. Anything else fails loudly rather
* than quietly reporting zero runs, which would turn rule 7 into a no-op.
*/
export function loadManifest() {
if (!exists(MANIFEST_FILE)) return { byDemo: new Map(), error: `${rel(MANIFEST_FILE)} does not exist` };
let raw;
try {
raw = readJson(MANIFEST_FILE);
} catch (error) {
return { byDemo: new Map(), error: `${rel(MANIFEST_FILE)} is not valid JSON: ${error.message}` };
}
/** @type {Map<string, {runs: any[], extras: Record<string, any>}>} */
const byDemo = new Map();
const container =
raw && typeof raw === 'object' && !Array.isArray(raw) && raw.demos && typeof raw.demos === 'object' ? raw.demos : raw;
if (Array.isArray(container)) {
// A flat array of runs, each carrying its own `demo`/`slug`.
for (const run of container) {
const slug = run?.demo ?? run?.slug;
if (typeof slug !== 'string') continue;
if (!byDemo.has(slug)) byDemo.set(slug, { runs: [], extras: {} });
byDemo.get(slug).runs.push(run);
}
} else if (container && typeof container === 'object') {
for (const [slug, value] of Object.entries(container)) {
if (Array.isArray(value)) byDemo.set(slug, { runs: value, extras: {} });
else if (value && typeof value === 'object' && Array.isArray(value.runs)) {
const { runs, ...extras } = value;
byDemo.set(slug, { runs, extras });
}
}
}
if (byDemo.size === 0) {
return {
byDemo,
error:
`${rel(MANIFEST_FILE)} did not parse into any demo runs. Expected one of: ` +
'{"<slug>": RunRef[]}, {"<slug>": {"runs": RunRef[], ...}}, {"demos": {...}}, ' +
'or a flat RunRef[] where each run carries a "demo" field.',
};
}
return { byDemo, error: null };
}
/** Resolve a site-absolute trace path ('/traces/x.json') to a disk path. */
export function traceFile(sitePath) {
const clean = String(sitePath).split('?')[0].split('#')[0];
return abs('public', clean.replace(/^\/+/, ''));
}
/* -------------------------------------------------------------- static server */
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.ttf': 'font/ttf',
'.txt': 'text/plain; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
};
/**
* Serves a directory the way the deploy host will: a real file if one exists,
* `index.html` otherwise.
*
* Prerender MUST hit the SPA fallback, so this deliberately does not 404 on an
* unknown route — but it DOES 404 on a missing file under a path with an
* extension, because serving HTML where a script was requested is how you
* prerender a blank page and never find out why.
*
* @param {string} dir
* @returns {Promise<{origin: string, close: () => Promise<void>}>}
*/
export function serveStatic(dir) {
const root = path.resolve(dir);
const server = http.createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://localhost');
const decoded = decodeURIComponent(url.pathname);
const safe = path.normalize(decoded).replace(/^(\.\.[/\\])+/, '');
let file = path.join(root, safe);
if (!file.startsWith(root)) {
res.writeHead(403).end('forbidden');
return;
}
if (exists(file) && fs.statSync(file).isDirectory()) file = path.join(file, 'index.html');
if (!exists(file)) {
if (/\.[a-z0-9]+$/i.test(safe)) {
res.writeHead(404, { 'content-type': 'text/plain' }).end(`not found: ${safe}`);
return;
}
file = path.join(root, 'index.html');
}
const body = fs.readFileSync(file);
res.writeHead(200, {
'content-type': MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
'content-length': body.length,
'cache-control': 'no-store',
});
res.end(body);
});
return new Promise((resolve, reject) => {
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
resolve({
origin: `http://127.0.0.1:${address.port}`,
close: () => new Promise((done) => server.close(() => done())),
});
});
});
}
/* ------------------------------------------------------------------- routing */
/** The public origin the baked tags point at. */
export const SITE_ORIGIN = (process.env.PIG_DEMO_ORIGIN ?? 'https://demo.primeintellectgrowth.com').replace(/\/+$/, '');
/**
* Every route pattern the app declares, read out of the router source.
*
* Hard-coding `['/', '/d/:slug']` here is the exact failure this function
* exists to avoid: the router moves, prerender keeps emitting the old paths,
* and every shared link previews as the homepage again — which is the bug
* prerendering was added to fix in the first place.
*/
export function discoverRoutePatterns() {
const files = walk(abs('src'), (f) => /\.tsx?$/.test(f));
/** @type {Set<string>} */
const patterns = new Set();
for (const file of files) {
const src = read(file);
for (const m of src.matchAll(/<Route\b[^>]*?\bpath\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*['"]([^'"]*)['"]\s*\})/g)) {
const value = m[1] ?? m[2] ?? m[3];
if (typeof value === 'string') patterns.add(value);
}
// `path:` is a common key name, so only trust it in a file that is
// demonstrably a router config.
if (/createBrowserRouter|createHashRouter|createMemoryRouter|RouteObject/.test(src)) {
for (const m of src.matchAll(/\bpath\s*:\s*(?:"([^"]*)"|'([^']*)')/g)) {
const value = m[1] ?? m[2];
if (typeof value === 'string') patterns.add(value);
}
}
}
return [...patterns];
}
const DEMO_PARAMS = /^(slug|demo|demoSlug)$/;
const VERTICAL_PARAMS = /^(vertical|verticalId|sector|category)$/;
/**
* Expands the router's patterns against the real data into concrete paths.
*
* @param {{metas: Map<string, any>, verticals: {id: string}[]}} data
* @returns {{routes: {path: string, kind: string, slug?: string, id?: string}[], errors: string[]}}
*/
export function expandRoutes(data) {
const patterns = discoverRoutePatterns();
/** @type {string[]} */
const errors = [];
/** @type {Map<string, any>} */
const routes = new Map();
const add = (p, record) => {
const normalised = p === '/' || p === '' ? '/' : `/${p.replace(/^\/+|\/+$/g, '')}`;
if (!routes.has(normalised)) routes.set(normalised, { path: normalised, ...record });
};
if (patterns.length === 0) {
errors.push(
'no route patterns found under src/. Looked for `<Route path=...>` and, in files that mention ' +
'createBrowserRouter/RouteObject, `path: ...`. Refusing to guess a route list.',
);
return { routes: [], errors };
}
const demoSlugList = [...data.metas.keys()];
const verticalIds = data.verticals.map((v) => v.id);
for (const pattern of patterns) {
if (pattern.includes('*')) continue; // the catch-all becomes 404.html, not a route
const params = [...pattern.matchAll(/:([A-Za-z0-9_]+)\??/g)].map((m) => m[1]);
if (params.length === 0) {
add(pattern, pattern === '/' || pattern === '' ? { kind: 'home' } : { kind: 'static' });
continue;
}
if (params.length > 1) {
errors.push(`route pattern "${pattern}" has more than one parameter; prerender cannot expand it.`);
continue;
}
const param = params[0];
const fill = (value, record) => add(pattern.replace(/:[A-Za-z0-9_]+\??/, value), record);
if (VERTICAL_PARAMS.test(param)) {
if (!verticalIds.length) errors.push(`route "${pattern}" needs verticals, but none were readable from src/content/verticals.ts.`);
for (const id of verticalIds) fill(id, { kind: 'vertical', id });
} else if (DEMO_PARAMS.test(param) || param === 'id') {
if (!demoSlugList.length) errors.push(`route "${pattern}" needs demos, but no demo meta was readable under src/demos/.`);
for (const slug of demoSlugList) fill(slug, { kind: 'demo', slug });
} else {
errors.push(
`route pattern "${pattern}" uses parameter ":${param}", which prerender cannot fill. ` +
'Name it :slug (a demo) or :vertical (a vertical), or teach scripts/_lib.mjs about it.',
);
}
}
if (!routes.has('/')) add('/', { kind: 'home' });
return { routes: [...routes.values()].sort((a, b) => (a.path < b.path ? -1 : 1)), errors };
}
/* ----------------------------------------------------------------- sizes */
export const gzipSize = (buf) => zlib.gzipSync(buf, { level: 9 }).length;
export const kb = (bytes) => `${(bytes / 1024).toFixed(1)} kB`;
/** Padded plain-text table. Beats taking a dependency to print eight rows. */
export function table(headers, rows) {
const all = [headers, ...rows];
const widths = headers.map((_, i) => Math.max(...all.map((r) => String(r[i] ?? '').length)));
const line = (cells, pad = ' ') =>
cells
.map((c, i) => (i === 0 ? String(c ?? '').padEnd(widths[i], pad) : String(c ?? '').padStart(widths[i], pad)))
.join(' ');
return [line(headers), line(widths.map((w) => '-'.repeat(w)), '-'), ...rows.map((r) => line(r))].join('\n');
}