408ce4a525
The site does no live inference. Rollouts are captured once against spark-1 and replayed at their recorded wall-clock — a public demo with no auth cannot hold an API key, and a recorded run can be scrubbed, permalinked, blind-compared and verified in ways a live one cannot. What stops it being a video is that the browser re-derives every number from the recorded moves. verify_fixtures.py is the Python half of that: it replays every committed fixture through the engine and reproduces its own rewards. All 16 land at delta 0.0. A fixture that cannot be regenerated is a claim with no receipt. First real measurement, thinking off, 8 seeds: solved 0/8. The model repeats guesses it has already played, invents words (trape, slith, postt, boomy), and contradicts its own feedback — consistency 0.09 to 0.17. That is the published failure taxonomy showing up in our own data on the first run, and it is why `consistency` is a reward component rather than a footnote. A capture failure is recorded as a turn with a null reply, never dropped. A capture that silently discarded failed turns would be reporting a better model than the one that ran. CI gates both halves and four things that fail silently in production: the word lists must rebuild byte-identically, the prerendered routes must carry their own baked og tags (crawlers do not run JS, so without them every shared link previews as the homepage), no blob: URL may reach the bundle (the site's CSP has no worker-src, so it falls back to default-src 'self' and a blob worker is blocked with no error), and the conformance digest must match across languages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
827 lines
29 KiB
JavaScript
827 lines
29 KiB
JavaScript
/**
|
|
* 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 literalsAfter(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;
|
|
};
|
|
const found = [];
|
|
let match;
|
|
while ((match = re.exec(src)) !== null) {
|
|
// The anchor may legally match inside a comment or a string \u2014 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;
|
|
const end = scanBalanced(src, from);
|
|
found.push({ text: src.slice(from, end), start: from, end });
|
|
// Skip past this literal so a nested anchor of the same name is not
|
|
// reported as a second, overlapping hit.
|
|
re.lastIndex = end;
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/** First literal following `anchor`, or null. See {@link literalsAfter}. */
|
|
export function literalAfter(src, anchor, open = '{') {
|
|
const [first] = literalsAfter(src, anchor, open);
|
|
return first ? first.text : null;
|
|
}
|
|
|
|
/** Marker key on the stand-in an unresolvable identifier evaluates to. */
|
|
export const UNRESOLVED = '__pigUnresolvedIdentifier__';
|
|
|
|
export const isUnresolved = (v) => Boolean(v) && typeof v === 'object' && UNRESOLVED in v;
|
|
|
|
/**
|
|
* A sandbox in which every free identifier resolves to a labelled stand-in.
|
|
*
|
|
* `RewardSpec.source.code` is legitimately an identifier — the Python is
|
|
* imported with `?raw` and cannot exist in plain Node — so the reward literal
|
|
* must be readable WITHOUT its `code`. Strict mode is still the default: a
|
|
* weight that turns out to be a stand-in is a contract failure, not a skip.
|
|
*/
|
|
function lenientSandbox() {
|
|
return new Proxy(Object.create(null), {
|
|
has: () => true,
|
|
get: (_target, key) => {
|
|
if (key === Symbol.unscopables) return undefined;
|
|
if (typeof key !== 'string') return undefined;
|
|
return { [UNRESOLVED]: key };
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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. Pass
|
|
* `{lenient: true}` to get stand-ins for free identifiers instead.
|
|
*
|
|
* @param {string} text
|
|
* @param {string} label
|
|
* @param {{lenient?: boolean}} [options]
|
|
*/
|
|
export function evalLiteral(text, label, options = {}) {
|
|
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 sandbox = options.lenient ? lenientSandbox() : Object.create(null);
|
|
const value = vm.runInNewContext(`(${js})`, sandbox, { 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 };
|
|
}
|
|
|
|
/** Every .ts/.tsx file that belongs to one demo. */
|
|
export function demoFiles(slug) {
|
|
return walk(path.join(DEMOS_DIR, slug), (f) => /\.tsx?$/.test(f));
|
|
}
|
|
|
|
/**
|
|
* Finds one named literal anywhere inside a demo's own source.
|
|
*
|
|
* A demo is free to put `reward` in `reward.ts` or inline it in `demo.tsx`;
|
|
* the contract is about the values, not the file layout. First match in
|
|
* filename order wins, and the file it came from is returned so failures can
|
|
* name it.
|
|
*
|
|
* @param {string} slug
|
|
* @param {RegExp[]} anchors
|
|
* @param {'{' | '['} open
|
|
* @param {string} label
|
|
* @param {{lenient?: boolean}} [options]
|
|
*/
|
|
export function findInDemo(slug, anchors, open, label, options = {}) {
|
|
for (const file of demoFiles(slug)) {
|
|
const src = read(file);
|
|
for (const anchor of anchors) {
|
|
let text = null;
|
|
try {
|
|
text = literalAfter(src, anchor, open);
|
|
} catch (error) {
|
|
return { file: rel(file), error: `could not brace-match the ${label} literal: ${error.message}` };
|
|
}
|
|
if (!text) continue;
|
|
const result = evalLiteral(text, label, options);
|
|
if (!result.ok) return { file: rel(file), error: result.error, text };
|
|
return { file: rel(file), value: result.value, text };
|
|
}
|
|
}
|
|
return {
|
|
file: null,
|
|
error: `no ${label} literal found in any .ts/.tsx file under ${rel(path.join(DEMOS_DIR, slug))}`,
|
|
};
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ 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,
|
|
// `anxiety` is this repo's field for the one line a vertical page leads
|
|
// with, and it is the only prose in a VerticalEntry short enough to be a
|
|
// meta description.
|
|
description: v?.description ?? v?.blurb ?? v?.tagline ?? v?.summary ?? v?.anxiety ?? 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();
|
|
// Unwrap the two envelopes the shell's own reader accepts. `{runs: [...]}`
|
|
// has to be unwrapped BEFORE the record branch below, or "runs" is read as a
|
|
// demo slug and every real demo reports zero runs.
|
|
let container = raw;
|
|
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
if (raw.demos && typeof raw.demos === 'object') container = raw.demos;
|
|
else if (Array.isArray(raw.runs)) container = raw.runs;
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
/**
|
|
* Which set fills a pattern's one parameter.
|
|
*
|
|
* Decided by the STATIC segment in front of it, not by the parameter's name.
|
|
* This router calls both of them `:slug` — `demos/:slug` and `verticals/:slug`
|
|
* — so a name-based reader confidently prerenders `/verticals/wordle`, twelve
|
|
* 404s, and no vertical pages at all.
|
|
*/
|
|
function fillFor(pattern) {
|
|
const head = pattern.replace(/^\/+/, '').split('/')[0]?.toLowerCase() ?? '';
|
|
if (/^(demos?|d)$/.test(head)) return 'demo';
|
|
if (/^(verticals?|v|sectors?)$/.test(head)) return 'vertical';
|
|
const param = pattern.match(/:([A-Za-z0-9_]+)/)?.[1] ?? '';
|
|
if (/^(vertical|verticalId|sector|category)$/.test(param)) return 'vertical';
|
|
if (/^(slug|demo|demoSlug|id)$/.test(param)) return 'demo';
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Expands the router's patterns against the real data into concrete paths.
|
|
*
|
|
* Child paths are taken as written. This router nests everything exactly one
|
|
* level under `/`, so a child's `path` is already the full path; a second level
|
|
* of nesting would need the parent prefix joined on, and this would silently
|
|
* emit the wrong routes rather than fail. If you nest deeper, fix this.
|
|
*
|
|
* @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 fill = fillFor(pattern);
|
|
const substitute = (value) => pattern.replace(/:[A-Za-z0-9_]+\??/, value);
|
|
if (fill === 'vertical') {
|
|
if (!verticalIds.length) errors.push(`route "${pattern}" needs verticals, but none were readable from ${rel(VERTICALS_FILE)}.`);
|
|
for (const id of verticalIds) add(substitute(id), { kind: 'vertical', id });
|
|
} else if (fill === 'demo') {
|
|
if (!demoSlugList.length) errors.push(`route "${pattern}" needs demos, but no demo meta was readable under src/demos/.`);
|
|
for (const slug of demoSlugList) add(substitute(slug), { kind: 'demo', slug });
|
|
} else {
|
|
errors.push(
|
|
`route pattern "${pattern}" has a parameter prerender cannot fill. Put it under /demos/ or ` +
|
|
'/verticals/, or teach fillFor() in 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');
|
|
}
|