/** * 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; } /** 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} | {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>} */ 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, 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 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'); }