Wordle module, cross-language tests, and the deploy path
The browser engine is a port of the Python one and CI proves it: all 21.2M (guess, answer) pairs hashed on both sides to the same SHA-256. Six TS tests, including the duplicate-letter table and the twelve pinned seed vectors that keep ?seed= permalinks pointing at the same word the recording used. Word lists are split by how they are used. answers.json is inlined because the board needs it before first paint to turn a seed into a word, and a fetch there means a visibly empty board on a cold cache. guesses.json is fetched, because it is three times larger and only needed the first time somebody presses Enter; until it lands, validation falls back to the answer list, which accepts strictly fewer words. The failure mode is 'your real word was briefly rejected', not 'a non-word was accepted' — the right way round. The solver runs in a worker constructed from a same-origin module URL, never Vite's ?worker&inline: that yields a blob:, and production CSP has no worker-src, so it falls back to default-src 'self' and the worker is blocked with no console error. It would fail in production only. deploy.sh smoke-tests the real public hostname from the deploying machine and fails on a body under 1 kB, because the bind bug's signature is a valid certificate over an empty 200 and a local --resolve check passes anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
@@ -401,8 +401,13 @@ export function parseStringUnion(src, typeName) {
|
||||
/* ------------------------------------------------------------ demo metadata */
|
||||
|
||||
const META_ANCHORS = [
|
||||
// `export default defineMeta({...})` is the shape the template uses, and the
|
||||
// one the registry's glob reads. It has to come first: a file can also carry
|
||||
// a `meta:` key inside something else, and that would win on file order.
|
||||
/\bdefineMeta\s*(?:<[^>]*>)?\s*\(\s*/,
|
||||
/(?:export\s+)?const\s+meta\s*(?::\s*[^=]+)?=\s*/,
|
||||
/(?:export\s+)?const\s+[A-Za-z_$][\w$]*Meta\s*(?::\s*[^=]+)?=\s*/,
|
||||
/export\s+default\s*/,
|
||||
/\bmeta\s*:\s*/,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The bundle budget.
|
||||
*
|
||||
* Two numbers, both gzipped, both enforced:
|
||||
*
|
||||
* entry chunk <= 160 kB what every visitor downloads before they see
|
||||
* anything, on whatever connection the boardroom
|
||||
* wifi is having that morning
|
||||
* any demo <= 90 kB a demo is lazy, so this is what clicking one
|
||||
* costs — and it is per demo, so the tenth demo
|
||||
* cannot be paid for by the first nine
|
||||
*
|
||||
* The vendor chunks (`react`, `charts`, named in vite.config.ts) are printed
|
||||
* but not capped. They are shared, cached across every route, and capping them
|
||||
* here would only produce pressure to inline them into the entry, which is the
|
||||
* opposite of what we want.
|
||||
*
|
||||
* Gzip, not brotli, and not raw: gzip is the floor every host actually serves,
|
||||
* so it is the honest number. Brotli would flatter us by about 15%.
|
||||
*
|
||||
* Run after a build: `pnpm build && node scripts/bundle-budget.mjs`.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { abs, die, dim, exists, green, gzipSize, kb, red, rel, table, yellow } from './_lib.mjs';
|
||||
|
||||
const ENTRY_BUDGET = 160 * 1024;
|
||||
const DEMO_BUDGET = 90 * 1024;
|
||||
|
||||
const DIST = abs('dist');
|
||||
const ASSETS = path.join(DIST, 'assets');
|
||||
const INDEX = path.join(DIST, 'index.html');
|
||||
|
||||
if (!exists(INDEX)) die(`${rel(INDEX)} does not exist. Run \`pnpm build\` first.`);
|
||||
if (!exists(ASSETS)) die(`${rel(ASSETS)} does not exist. The build produced no assets, which is itself the bug.`);
|
||||
|
||||
/**
|
||||
* The entry is whatever `index.html` loads directly.
|
||||
*
|
||||
* Derived rather than matched by filename: Vite's entry is `index-<hash>.js`
|
||||
* today, but the moment someone renames the entry in rollupOptions, a
|
||||
* name-matching version of this script starts measuring nothing and passing.
|
||||
*/
|
||||
const indexHtml = fs.readFileSync(INDEX, 'utf8');
|
||||
const entryNames = new Set(
|
||||
[...indexHtml.matchAll(/<script[^>]+type=["']module["'][^>]+src=["']([^"']+)["']/g)].map((m) => path.basename(m[1])),
|
||||
);
|
||||
if (entryNames.size === 0) {
|
||||
die(`no module <script> found in ${rel(INDEX)}, so the entry chunk cannot be identified.`);
|
||||
}
|
||||
|
||||
/** Named in vite.config.ts's manualChunks. Shared, cached, deliberately uncapped. */
|
||||
const VENDOR = /^(react|charts)-/;
|
||||
|
||||
const files = fs
|
||||
.readdirSync(ASSETS)
|
||||
.filter((name) => name.endsWith('.js') || name.endsWith('.css'))
|
||||
.sort();
|
||||
|
||||
const rows = [];
|
||||
const failures = [];
|
||||
let totalGzip = 0;
|
||||
|
||||
for (const name of files) {
|
||||
const bytes = fs.readFileSync(path.join(ASSETS, name));
|
||||
const gz = gzipSize(bytes);
|
||||
totalGzip += gz;
|
||||
|
||||
const kind = entryNames.has(name)
|
||||
? 'entry'
|
||||
: name.endsWith('.css')
|
||||
? 'css'
|
||||
: VENDOR.test(name)
|
||||
? 'vendor'
|
||||
: 'demo/lazy';
|
||||
|
||||
const budget = kind === 'entry' ? ENTRY_BUDGET : kind === 'demo/lazy' ? DEMO_BUDGET : null;
|
||||
const over = budget !== null && gz > budget;
|
||||
if (over) {
|
||||
failures.push(
|
||||
`assets/${name} is ${kb(gz)} gzipped, over the ${kb(budget)} ${kind === 'entry' ? 'entry' : 'per-demo'} budget ` +
|
||||
`by ${kb(gz - budget)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
rows.push([
|
||||
`assets/${name}`,
|
||||
kind,
|
||||
kb(bytes.length),
|
||||
kb(gz),
|
||||
budget === null ? '-' : `${((gz / budget) * 100).toFixed(0)}%`,
|
||||
over ? red('OVER') : budget === null ? dim('n/a') : green('ok'),
|
||||
]);
|
||||
}
|
||||
|
||||
console.log(table(['file', 'kind', 'raw', 'gzip', 'of budget', ''], rows));
|
||||
console.log('');
|
||||
console.log(dim(`total gzipped: ${kb(totalGzip)} across ${rows.length} assets`));
|
||||
|
||||
if (failures.length) {
|
||||
console.error('');
|
||||
for (const failure of failures) console.error(`${red('FAIL')} ${failure}`);
|
||||
console.error('');
|
||||
console.error(
|
||||
red('bundle-budget: over budget.') +
|
||||
'\n A demo that costs 90 kB is a demo somebody closes before it renders. The usual causes, in ' +
|
||||
'\n order: a chart library pulled into a demo chunk instead of its own tab, an icon set imported ' +
|
||||
"\n as a namespace rather than by name, and a `?raw` receipt that grew a file it didn't need.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// A lazy chunk nobody can reach is not a saving, it is dead weight that still
|
||||
// costs disk and cache. Worth a look, never worth failing a build over.
|
||||
const unreferenced = rows.filter((row) => row[1] === 'demo/lazy' && !indexHtml.includes(path.basename(row[0])));
|
||||
if (unreferenced.length > 8) {
|
||||
console.log(yellow(`warn ${unreferenced.length} lazy chunks. Worth checking none of them are orphaned.`));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(green('bundle-budget: within budget.'));
|
||||
@@ -514,7 +514,8 @@ function probeAdapter(modulePath, traces) {
|
||||
const probe = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'pig-adapt-')), 'probe.mts');
|
||||
fs.writeFileSync(probe, adapterProbeSource(), 'utf8');
|
||||
try {
|
||||
const run = spawnSync(process.execPath, [tsx, probe, modulePath, ...traces], {
|
||||
// The tsx bin is a POSIX shell shim, not a JS file: run it directly.
|
||||
const run = spawnSync(tsx, [probe, modulePath, ...traces], {
|
||||
cwd: abs('.'),
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The cross-language conformance gate.
|
||||
*
|
||||
* `envs/wordle_five/wordle_five/engine.py` is the reference implementation and
|
||||
* `src/demos/wordle/engine.ts` is a port of it. The site's entire argument is
|
||||
* that the board you play in the browser is the same environment the model was
|
||||
* evaluated in, so the two implementations agreeing is not a nice-to-have — it
|
||||
* is the claim.
|
||||
*
|
||||
* A hand-written vector file would only ever catch the cases somebody thought
|
||||
* of, and the case nobody thinks of is always the same one: repeated letters,
|
||||
* where a green must claim its letter before any yellow is assigned. So the
|
||||
* gate is exhaustive instead. For every answer A in `words/answers.json`, in
|
||||
* list order, concatenate `score(G, A)` for every guess G in that same list, in
|
||||
* that same order, and stream the whole thing through SHA-256. That is the
|
||||
* digest `engine.py`'s `conformance_digest()` computes, and it is the digest
|
||||
* this script computes from the TypeScript.
|
||||
*
|
||||
* Both the answer list order and the two nested loop orders are part of the
|
||||
* definition. Sorting the list, deduplicating it, or swapping the loops all
|
||||
* produce a different, equally valid, completely useless number.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/conformance.mjs full digest, compared to CONFORMANCE.txt
|
||||
* node scripts/conformance.mjs --limit 50 first 50 answers only, no comparison
|
||||
*
|
||||
* Exits 0 with a note (not an error) when `engine.ts` does not exist yet — the
|
||||
* TypeScript port and this script are allowed to arrive in either order.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { abs, cyan, die, dim, exists, green, read, red, rel } from './_lib.mjs';
|
||||
|
||||
const ANSWERS = abs('envs', 'wordle_five', 'words', 'answers.json');
|
||||
const EXPECTED_FILE = abs('envs', 'wordle_five', 'CONFORMANCE.txt');
|
||||
|
||||
const limitFlag = process.argv.indexOf('--limit');
|
||||
const limit = limitFlag === -1 ? 0 : Number(process.argv[limitFlag + 1] ?? 0);
|
||||
if (limitFlag !== -1 && (!Number.isInteger(limit) || limit <= 0)) {
|
||||
die('--limit takes a positive integer, e.g. `--limit 50`.');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ find the TS engine */
|
||||
|
||||
function findEngine() {
|
||||
const preferred = abs('src', 'demos', 'wordle', 'engine.ts');
|
||||
if (exists(preferred)) return preferred;
|
||||
const demosDir = abs('src', 'demos');
|
||||
if (!exists(demosDir)) return null;
|
||||
const found = fs
|
||||
.readdirSync(demosDir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith('_'))
|
||||
.map((e) => path.join(demosDir, e.name, 'engine.ts'))
|
||||
.filter(exists);
|
||||
// More than one engine.ts and no `wordle` directory means there is nothing to
|
||||
// guess between; say so rather than silently conforming the wrong one.
|
||||
return found.length === 1 ? found[0] : null;
|
||||
}
|
||||
|
||||
const engine = findEngine();
|
||||
if (!engine) {
|
||||
console.log(dim('conformance: engine not present yet — no src/demos/wordle/engine.ts to check.'));
|
||||
console.log(dim('The TypeScript port and this gate may land in either order; re-run once it exists.'));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!exists(ANSWERS)) die(`${rel(ANSWERS)} does not exist. It defines both the pairs and their order.`);
|
||||
if (!exists(abs('node_modules', '.bin', 'tsx'))) die('tsx is not installed, and the engine is TypeScript. Run `pnpm install`.');
|
||||
|
||||
/* ------------------------------------------------------------------- run it */
|
||||
|
||||
const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pig-conformance-'));
|
||||
const probe = path.join(probeDir, 'digest.mts');
|
||||
fs.writeFileSync(probe, probeSource(), 'utf8');
|
||||
|
||||
const started = Date.now();
|
||||
console.log(dim(`conformance: hashing every (guess, answer) pair through ${rel(engine)}...`));
|
||||
|
||||
// The tsx bin is a POSIX shell shim, not a JS file, so it is the executable
|
||||
// here rather than an argument to node.
|
||||
const run = spawnSync(abs('node_modules', '.bin', 'tsx'), [probe, engine, ANSWERS, String(limit)], {
|
||||
cwd: abs('.'),
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
timeout: 15 * 60_000,
|
||||
// The probe lives in a temp directory, where tsx would never find the
|
||||
// repo's tsconfig — and without it the `@/` alias in engine.ts does not
|
||||
// resolve and the import fails for a reason that looks nothing like the cause.
|
||||
env: { ...process.env, TSX_TSCONFIG_PATH: abs('tsconfig.json') },
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
});
|
||||
fs.rmSync(probeDir, { recursive: true, force: true });
|
||||
|
||||
if (run.error) die(`could not run the digest probe: ${run.error.message}`);
|
||||
if (run.status !== 0) die(`the digest probe exited ${run.status}. See the error above.`);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(String(run.stdout).trim().split('\n').pop());
|
||||
} catch {
|
||||
die(`the digest probe produced no parseable result. Output was:\n${run.stdout}`);
|
||||
}
|
||||
if (result.error) {
|
||||
die(
|
||||
`${rel(engine)}: ${result.error}\n` +
|
||||
' The gate needs a scoring function it can recognise. Export one named `scoreGuess` (or `score`), ' +
|
||||
'taking (guess, answer) and returning the pattern — either a "GYXXG" string or an array of ' +
|
||||
"'exact' | 'present' | 'absent' tiles. Exporting `conformanceDigest()` directly also works.",
|
||||
);
|
||||
}
|
||||
|
||||
const seconds = ((Date.now() - started) / 1000).toFixed(1);
|
||||
console.log(` scored ${result.pairs.toLocaleString('en-US')} pairs from ${result.answers.toLocaleString('en-US')} answers in ${seconds}s`);
|
||||
console.log(` via ${cyan(result.via)}`);
|
||||
console.log(` digest ${cyan(result.digest)}`);
|
||||
|
||||
if (limit) {
|
||||
console.log('');
|
||||
console.log(dim(`Partial run (--limit ${limit}): not compared to ${rel(EXPECTED_FILE)}. Run without --limit for the real gate.`));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- compare it */
|
||||
|
||||
if (!exists(EXPECTED_FILE)) {
|
||||
console.log('');
|
||||
console.log(green('conformance: computed, but there is nothing to compare against yet.'));
|
||||
console.log(`Write the digest to ${rel(EXPECTED_FILE)} once the Python agrees:`);
|
||||
console.log(dim(` python -c "from wordle_five.engine import conformance_digest; print(conformance_digest())"`));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const expected = read(EXPECTED_FILE).match(/\b[0-9a-f]{64}\b/)?.[0];
|
||||
if (!expected) {
|
||||
die(`${rel(EXPECTED_FILE)} contains no 64-character hex digest.`);
|
||||
}
|
||||
|
||||
if (expected !== result.digest) {
|
||||
console.error('');
|
||||
console.error(`${red('FAIL')} the TypeScript engine and ${rel(EXPECTED_FILE)} disagree.`);
|
||||
console.error(` expected ${expected}`);
|
||||
console.error(` got ${result.digest}`);
|
||||
console.error('');
|
||||
console.error(
|
||||
' One of the two implementations is wrong, and the board on the site is therefore not the\n' +
|
||||
' environment the model was evaluated in. The usual culprit is the two-pass scoring: every\n' +
|
||||
' green must claim its letter out of the pool BEFORE any yellow is assigned, or a guess like\n' +
|
||||
' SASSY against BASIS marks an S yellow that the greens have already spent.\n' +
|
||||
' If the Python changed on purpose, re-run its own `conformance_digest()` and update the file.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(green(`conformance: the TypeScript engine matches ${rel(EXPECTED_FILE)} exactly.`));
|
||||
|
||||
/* ------------------------------------------------------------------ probe */
|
||||
|
||||
/**
|
||||
* Runs under tsx so it can import the TypeScript engine directly.
|
||||
*
|
||||
* Kept here as a string rather than as a checked-in `.mts`: it is an
|
||||
* implementation detail of this script, and a stray TypeScript file in
|
||||
* `scripts/` would be swept into a typecheck it is deliberately outside of.
|
||||
*
|
||||
* A function declaration, not a `const`, so it is hoisted above the code that
|
||||
* writes it to disk near the top of this file.
|
||||
*/
|
||||
function probeSource() {
|
||||
return `
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const [enginePath, answersPath, limitRaw] = process.argv.slice(2);
|
||||
const limit = Number(limitRaw ?? 0);
|
||||
const say = (value: unknown) => console.log(JSON.stringify(value));
|
||||
|
||||
let mod: any;
|
||||
try {
|
||||
mod = await import(enginePath);
|
||||
} catch (error: any) {
|
||||
say({ error: 'could not import it: ' + String(error?.message ?? error).split('\\n')[0] });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const answers: string[] = JSON.parse(readFileSync(answersPath, 'utf8'));
|
||||
const pool = limit > 0 ? answers.slice(0, limit) : answers;
|
||||
|
||||
// If the engine ships the digest itself, trust it over anything reconstructed
|
||||
// here — it is the implementation's own statement of the definition.
|
||||
if (typeof mod.conformanceDigest === 'function' && limit === 0) {
|
||||
const digest = await mod.conformanceDigest();
|
||||
say({ digest, via: 'engine.conformanceDigest()', pairs: pool.length * pool.length, answers: pool.length });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Argument order is the Python's: score(guess, answer). Getting it backwards
|
||||
// produces a valid-looking digest that matches nothing, so if this disagrees
|
||||
// with CONFORMANCE.txt, check the signature before you check the algorithm.
|
||||
const NAMES = ['scoreGuess', 'score', 'scorePattern', 'scoreWord', 'feedback', 'pattern'];
|
||||
const name =
|
||||
NAMES.find((n) => typeof mod[n] === 'function' && mod[n].length >= 2) ??
|
||||
NAMES.find((n) => typeof mod[n] === 'function') ??
|
||||
(typeof mod.default === 'function' ? 'default' : null);
|
||||
|
||||
if (!name) {
|
||||
say({ error: 'no scoring function is exported. Found: ' + Object.keys(mod).join(', ') });
|
||||
process.exit(0);
|
||||
}
|
||||
const score = mod[name];
|
||||
|
||||
/** Every spelling of green/yellow/grey this port might reasonably have chosen. */
|
||||
const TILE: Record<string, string> = {
|
||||
g: 'G', y: 'Y', x: 'X',
|
||||
green: 'G', yellow: 'Y', grey: 'X', gray: 'X',
|
||||
exact: 'G', present: 'Y', absent: 'X',
|
||||
correct: 'G', misplaced: 'Y', miss: 'X', hit: 'G',
|
||||
};
|
||||
|
||||
function normalise(value: any): string {
|
||||
if (typeof value === 'string') {
|
||||
const upper = value.toUpperCase();
|
||||
if (/^[GYX]+$/.test(upper)) return upper;
|
||||
const chars = [...value].map((c) => TILE[c.toLowerCase()]);
|
||||
if (chars.every(Boolean)) return chars.join('');
|
||||
throw new Error('unrecognised pattern string ' + JSON.stringify(value));
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((tile) => {
|
||||
const key = typeof tile === 'string' ? tile : tile?.state ?? tile?.tile ?? tile?.kind ?? tile?.result ?? tile?.status;
|
||||
const mapped = TILE[String(key).toLowerCase()];
|
||||
if (!mapped) throw new Error('unrecognised tile ' + JSON.stringify(tile));
|
||||
return mapped;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
throw new Error('unrecognised pattern ' + JSON.stringify(value));
|
||||
}
|
||||
|
||||
let convert: (value: any) => string;
|
||||
try {
|
||||
const sample = score(pool[0], pool[0]);
|
||||
// 21 million conversions: skip the whole normaliser when the engine already
|
||||
// speaks the reference alphabet, which is the case that actually ships.
|
||||
convert = typeof sample === 'string' && /^[GYX]+$/.test(sample) ? (v: any) => v : normalise;
|
||||
const selfScore = convert(sample);
|
||||
if (!/^G+$/.test(selfScore)) {
|
||||
say({ error: 'score(w, w) returned "' + selfScore + '"; a word scored against itself must be all-green' });
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (error: any) {
|
||||
say({ error: String(error?.message ?? error) });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const hash = createHash('sha256');
|
||||
const row = new Array<string>(pool.length);
|
||||
for (let i = 0; i < pool.length; i += 1) {
|
||||
const answer = pool[i]!;
|
||||
for (let j = 0; j < pool.length; j += 1) row[j] = convert(score(pool[j]!, answer));
|
||||
hash.update(row.join(''));
|
||||
// Only on a TTY: written to a pipe or a CI log, a carriage return is not a
|
||||
// cursor move and the progress ends up inline with the result.
|
||||
if (i % 250 === 0 && process.stderr.isTTY) process.stderr.write(' ' + i + '/' + pool.length + ' answers\\r');
|
||||
}
|
||||
if (process.stderr.isTTY) process.stderr.write(' '.repeat(40) + '\\r');
|
||||
|
||||
say({ digest: hash.digest('hex'), via: 'engine.' + name + '(guess, answer)', pairs: pool.length * pool.length, answers: pool.length });
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* `node scripts/new-demo.mjs <slug>` — scaffold a demo.
|
||||
*
|
||||
* Copies `src/demos/_template` to `src/demos/<slug>` and `envs/_template` to
|
||||
* `envs/<package>`, substitutes the name everywhere, and wires NOTHING. There
|
||||
* is deliberately no registry to edit, no route to add and no import to insert:
|
||||
* the registry is an `import.meta.glob`, so a demo exists because its directory
|
||||
* exists. If you ever find yourself adding a line to a shared file to make a new
|
||||
* demo appear, that is a bug in the registry, not a missing step here.
|
||||
*
|
||||
* ── SUBSTITUTIONS ──────────────────────────────────────────────────────────
|
||||
*
|
||||
* For `new-demo.mjs wordle-five`, in every copied file's CONTENT and in its
|
||||
* PATH:
|
||||
*
|
||||
* __slug__ wordle-five the directory name, meta.slug, the URL
|
||||
* __package__ wordle_five the Python package under envs/
|
||||
* __Title__ Wordle Five a starting point for meta.title
|
||||
* __Pascal__ WordleFive component and type names
|
||||
* __camel__ wordleFive variable names
|
||||
* __SLUG__ WORDLE_FIVE constants
|
||||
* _template wordle-five (wordle_five under envs/)
|
||||
*
|
||||
* Anything still matching `__word__` after the copy is reported, because a
|
||||
* template token that survives into a real demo compiles fine and ships a
|
||||
* placeholder to production.
|
||||
*
|
||||
* The slug must be kebab-case, must not already exist, and must not collide
|
||||
* with a top-level route.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { abs, cyan, dim, exists, green, red, rel } from './_lib.mjs';
|
||||
|
||||
const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
||||
|
||||
/** Top-level paths the router already owns. A demo here would be unreachable. */
|
||||
const RESERVED = new Set(['gallery', 'demos', 'verticals', 'honesty', 'assets', 'og', 'traces', 'fonts', 'icons']);
|
||||
|
||||
const slug = process.argv[2];
|
||||
|
||||
if (!slug || slug === '--help' || slug === '-h') {
|
||||
console.log('usage: node scripts/new-demo.mjs <slug>\n');
|
||||
console.log(' <slug> kebab-case, e.g. support-refund-triage. Becomes the directory name,');
|
||||
console.log(' meta.slug and the URL /demos/<slug>.');
|
||||
process.exit(slug ? 0 : 1);
|
||||
}
|
||||
|
||||
const fail = (message, hint) => {
|
||||
console.error(`${red('FAIL')} ${message}`);
|
||||
if (hint) console.error(` ${hint}`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
if (!KEBAB.test(slug)) {
|
||||
fail(
|
||||
`"${slug}" is not kebab-case.`,
|
||||
'Lower-case letters and digits, single hyphens between words, starting with a letter. ' +
|
||||
'It is the URL, the directory name and the registry key, so it has exactly one spelling.',
|
||||
);
|
||||
}
|
||||
if (RESERVED.has(slug)) {
|
||||
fail(`"${slug}" is a reserved path.`, `The router already owns /${slug}. Reserved: ${[...RESERVED].join(', ')}.`);
|
||||
}
|
||||
|
||||
const pkg = slug.replace(/-/g, '_');
|
||||
const words = slug.split('-');
|
||||
const substitutions = [
|
||||
['__package__', pkg],
|
||||
['__Pascal__', words.map(capitalise).join('')],
|
||||
['__camel__', words.map((w, i) => (i === 0 ? w : capitalise(w))).join('')],
|
||||
['__Title__', words.map(capitalise).join(' ')],
|
||||
['__SLUG__', pkg.toUpperCase()],
|
||||
['__slug__', slug],
|
||||
];
|
||||
|
||||
const jobs = [
|
||||
{ from: abs('src', 'demos', '_template'), to: abs('src', 'demos', slug), bareName: slug },
|
||||
{ from: abs('envs', '_template'), to: abs('envs', pkg), bareName: pkg },
|
||||
];
|
||||
|
||||
for (const job of jobs) {
|
||||
if (!exists(job.from)) {
|
||||
fail(
|
||||
`${rel(job.from)} does not exist.`,
|
||||
'The template is the contract in worked-example form; scaffolding from nothing would produce a demo ' +
|
||||
'that satisfies no rule in scripts/check-demos.mjs.',
|
||||
);
|
||||
}
|
||||
if (exists(job.to)) {
|
||||
fail(
|
||||
`${rel(job.to)} already exists.`,
|
||||
'Refusing to overwrite. Pick another slug, or delete the directory yourself if you meant to start over.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- copy */
|
||||
|
||||
const written = [];
|
||||
const leftovers = new Map();
|
||||
|
||||
for (const job of jobs) {
|
||||
copyTree(job.from, job.to, job.bareName);
|
||||
}
|
||||
|
||||
console.log(green(`Created ${written.length} files:`));
|
||||
for (const file of written) console.log(dim(` ${rel(file)}`));
|
||||
|
||||
if (leftovers.size > 0) {
|
||||
console.log('');
|
||||
console.log(red('Unsubstituted template tokens remain:'));
|
||||
for (const [token, files] of leftovers) {
|
||||
console.log(` ${token} in ${[...files].map(rel).join(', ')}`);
|
||||
}
|
||||
console.log('');
|
||||
console.log('Either add the token to the substitution table in scripts/new-demo.mjs, or fix the template.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`Next, in ${cyan(rel(abs('src', 'demos', slug)))}:`);
|
||||
console.log(' 1. meta.ts — title, tagline, vertical, persona, rewardLine, the lucide icon name');
|
||||
console.log(' 2. demo.tsx — the narrative, the anatomy, the reward, and the Surface');
|
||||
console.log(` 3. ${rel(abs('envs', pkg))} — the environment the reward quotes`);
|
||||
console.log('');
|
||||
console.log(`Then ${cyan('pnpm check')}. Nothing else needs editing — the registry finds the demo by existence.`);
|
||||
console.log(dim(`The demo will 404 until meta.ts, demo.tsx and public/og/${slug}.png all exist.`));
|
||||
|
||||
/* ----------------------------------------------------------------- helpers */
|
||||
|
||||
function capitalise(word) {
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
}
|
||||
|
||||
/** Applies the substitution table, longest token first so prefixes cannot win. */
|
||||
function substitute(text, bareName) {
|
||||
let out = text;
|
||||
for (const [token, value] of substitutions) out = out.split(token).join(value);
|
||||
// `_template` is what the directory is literally called, so it turns up in
|
||||
// relative imports and in the Python package name inside pyproject.toml.
|
||||
return out.split('_template').join(bareName);
|
||||
}
|
||||
|
||||
function copyTree(from, to, bareName) {
|
||||
fs.mkdirSync(to, { recursive: true });
|
||||
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
||||
if (entry.name === '__pycache__' || entry.name === 'node_modules') continue;
|
||||
const source = path.join(from, entry.name);
|
||||
const target = path.join(to, substitute(entry.name, bareName));
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
copyTree(source, target, bareName);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
|
||||
// Binary files (a fixture PNG in the template, say) are copied byte for
|
||||
// byte. Running them through the string substitution would corrupt them.
|
||||
if (isBinary(source)) {
|
||||
fs.copyFileSync(source, target);
|
||||
written.push(target);
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = substitute(fs.readFileSync(source, 'utf8'), bareName);
|
||||
fs.writeFileSync(target, body, 'utf8');
|
||||
written.push(target);
|
||||
|
||||
for (const match of body.matchAll(/__[A-Za-z][A-Za-z0-9]*__/g)) {
|
||||
if (!leftovers.has(match[0])) leftovers.set(match[0], new Set());
|
||||
leftovers.get(match[0]).add(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isBinary(file) {
|
||||
return /\.(png|jpe?g|gif|webp|avif|ico|woff2?|ttf|otf|pdf|zip|gz)$/i.test(file);
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Renders the social card for every route.
|
||||
*
|
||||
* One 1200x630 PNG per route, screenshotted from the real built page, written
|
||||
* to `public/og/`. Not generated from a template: a card drawn by a separate
|
||||
* renderer drifts from the page it advertises, and this way a change to the
|
||||
* board or the palette shows up in the card by construction.
|
||||
*
|
||||
* ── ORDERING ───────────────────────────────────────────────────────────────
|
||||
*
|
||||
* This reads `dist/` and writes `public/`, which means the cards are always one
|
||||
* build behind until you build again:
|
||||
*
|
||||
* pnpm build && node scripts/og.mjs && pnpm build
|
||||
*
|
||||
* That is deliberate. The alternative — screenshotting the dev server — renders
|
||||
* unminified CSS and a different font-loading path, and the cards came out
|
||||
* subtly wrong in a way nobody noticed until they were on X.
|
||||
*
|
||||
* ── WHERE THIS RUNS ────────────────────────────────────────────────────────
|
||||
*
|
||||
* amd-server (x86), where Playwright's chromium is installed. It is NOT part of
|
||||
* the deploy: the deploy host has no browser, and a deploy that silently
|
||||
* skipped card generation would ship a site whose every link previews as a
|
||||
* broken image. Run it here, commit the PNGs, deploy the PNGs.
|
||||
*
|
||||
* Env:
|
||||
* PIG_OG_SCALE device pixel ratio, default 2 (so the file is 2400x1260 for
|
||||
* a 1200x630 CSS-pixel card; every unfurler downsamples, and
|
||||
* 1x text on a retina timeline looks like a fax).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { abs, die, dim, exists, expandRoutes, green, loadAllMetas, loadVerticals, rel, serveStatic, table } from './_lib.mjs';
|
||||
|
||||
const WIDTH = 1200;
|
||||
const HEIGHT = 630;
|
||||
const SCALE = Number(process.env.PIG_OG_SCALE ?? 2);
|
||||
|
||||
const DIST = abs('dist');
|
||||
const OUT_DIR = abs('public', 'og');
|
||||
|
||||
if (!exists(path.join(DIST, 'index.html'))) {
|
||||
die(`${rel(DIST)}/index.html does not exist. Run \`pnpm build\` first — the cards are shot from the built site.`);
|
||||
}
|
||||
|
||||
const { chromium } = await import('playwright').catch(() => {
|
||||
die('playwright is not installed. `pnpm install` first.');
|
||||
});
|
||||
|
||||
const executable = chromium.executablePath();
|
||||
if (!exists(executable)) {
|
||||
die(
|
||||
`Playwright's chromium is not installed at ${executable}.\n` +
|
||||
' Run this on amd-server (x86), where it is installed, and commit the PNGs. ' +
|
||||
'The deploy host has no browser, which is exactly why card generation is not part of the deploy.',
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- routes -> filenames */
|
||||
|
||||
const { metas } = loadAllMetas();
|
||||
const verticals = loadVerticals();
|
||||
if (verticals.error) die(`could not read the verticals: ${verticals.error}`);
|
||||
|
||||
const { routes, errors } = expandRoutes({ metas, verticals: verticals.list });
|
||||
if (errors.length) die(`route enumeration failed:\n - ${errors.join('\n - ')}`);
|
||||
|
||||
/**
|
||||
* A demo's filename comes from its own `meta.ogImage`, not from its slug.
|
||||
*
|
||||
* `check-demos` rule 4 asserts that file exists; if this script invented a
|
||||
* different name, the two would disagree and the check would fail on a card
|
||||
* that had just been generated.
|
||||
*/
|
||||
function outputName(route) {
|
||||
if (route.kind === 'home') return 'home.png';
|
||||
if (route.kind === 'demo') {
|
||||
const declared = metas.get(route.slug)?.ogImage;
|
||||
return typeof declared === 'string' && declared.trim() !== '' ? path.basename(declared) : `${route.slug}.png`;
|
||||
}
|
||||
// Prefixed, because a vertical slug and a demo slug live in the same
|
||||
// directory and nothing stops them colliding.
|
||||
if (route.kind === 'vertical') return `vertical-${route.id}.png`;
|
||||
return `${route.path.replace(/^\/+/, '').replace(/\//g, '-') || 'home'}.png`;
|
||||
}
|
||||
|
||||
const targets = routes.map((route) => ({ route, name: outputName(route) }));
|
||||
|
||||
const byName = new Map();
|
||||
for (const target of targets) {
|
||||
const clash = byName.get(target.name);
|
||||
if (clash) {
|
||||
die(
|
||||
`${target.route.path} and ${clash.path} would both write public/og/${target.name}. ` +
|
||||
'Change one of their `meta.ogImage` values; a shared card means one of the two pages advertises the other.',
|
||||
);
|
||||
}
|
||||
byName.set(target.name, target.route);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- screenshot */
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
const server = await serveStatic(DIST);
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: WIDTH, height: HEIGHT },
|
||||
deviceScaleFactor: SCALE,
|
||||
colorScheme: 'light',
|
||||
// Every animation on the site is an entrance. Shooting mid-flight catches
|
||||
// elements at 40% opacity, which reads as a rendering bug in the card.
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
|
||||
const rows = [];
|
||||
try {
|
||||
for (const { route, name } of targets) {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${server.origin}${route.path}`, { waitUntil: 'load', timeout: 30_000 });
|
||||
await page.waitForSelector('#root > *', { timeout: 30_000 }).catch(() => {});
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
|
||||
await page.evaluate(() => document.fonts.ready.then(() => true)).catch(() => {});
|
||||
|
||||
const file = path.join(OUT_DIR, name);
|
||||
// `clip` rather than fullPage: the card is a 1200x630 window onto the top
|
||||
// of the page, and fullPage would hand the unfurler a 1200x9000 strip that
|
||||
// every platform crops to something arbitrary.
|
||||
await page.screenshot({ path: file, clip: { x: 0, y: 0, width: WIDTH, height: HEIGHT } });
|
||||
await page.close();
|
||||
|
||||
rows.push([route.path, `public/og/${name}`, `${(fs.statSync(file).size / 1024).toFixed(0)} kB`]);
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
|
||||
console.log(table(['route', 'card', 'size'], rows));
|
||||
console.log('');
|
||||
console.log(green(`og: wrote ${rows.length} cards at ${WIDTH}x${HEIGHT} CSS px (x${SCALE}).`));
|
||||
console.log(dim('Rebuild before deploying, or dist/ still holds the previous cards.'));
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* `dist/sitemap.xml` and `robots.txt`.
|
||||
*
|
||||
* PIG's own site is `noindex` — it is a private tool. This one is the exact
|
||||
* inverse: it exists to be found, shared and quoted, so the robots file opens
|
||||
* everything and points at a sitemap listing every route the router actually
|
||||
* produces.
|
||||
*
|
||||
* The route list is the same one `prerender.mjs` bakes, from the same reader,
|
||||
* so a sitemap entry cannot point at a URL that has no prerendered document.
|
||||
* Those two drifting apart is how you end up submitting 14 URLs and having 13
|
||||
* of them indexed as the homepage.
|
||||
*
|
||||
* `robots.txt` is written into `public/` (source, committed) AND into `dist/`
|
||||
* (because this runs after the build that would have copied it). The sitemap is
|
||||
* a build artifact only — it lists what this build contains.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { SITE_ORIGIN, abs, die, exists, expandRoutes, green, loadAllMetas, loadVerticals, rel } from './_lib.mjs';
|
||||
|
||||
const DIST = abs('dist');
|
||||
const PUBLIC = abs('public');
|
||||
|
||||
const { metas } = loadAllMetas();
|
||||
const verticals = loadVerticals();
|
||||
if (verticals.error) die(`could not read the verticals: ${verticals.error}`);
|
||||
|
||||
const { routes, errors } = expandRoutes({ metas, verticals: verticals.list });
|
||||
if (errors.length) die(`route enumeration failed:\n - ${errors.join('\n - ')}`);
|
||||
|
||||
/* ------------------------------------------------------------- robots.txt */
|
||||
|
||||
const robots = [
|
||||
'# demo.primeintellectgrowth.com',
|
||||
'#',
|
||||
'# This site is meant to be found. Every page is public, static and safe to',
|
||||
'# crawl; the source it documents is public too.',
|
||||
'',
|
||||
'User-agent: *',
|
||||
'Allow: /',
|
||||
'',
|
||||
`Sitemap: ${SITE_ORIGIN}/sitemap.xml`,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.mkdirSync(PUBLIC, { recursive: true });
|
||||
fs.writeFileSync(path.join(PUBLIC, 'robots.txt'), robots, 'utf8');
|
||||
|
||||
/* ------------------------------------------------------------ sitemap.xml */
|
||||
|
||||
if (!exists(path.join(DIST, 'index.html'))) {
|
||||
die(
|
||||
`${rel(DIST)}/index.html does not exist, so there is no build to describe. ` +
|
||||
'Run `pnpm build` first. (public/robots.txt has been written.)',
|
||||
);
|
||||
}
|
||||
|
||||
// One date for the whole build. Per-file mtimes would claim the vertical pages
|
||||
// changed whenever anything in the bundle did, which is true of a hash-named
|
||||
// asset and useless to a crawler.
|
||||
const lastmod = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const xml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
...routes.map((route) =>
|
||||
[
|
||||
' <url>',
|
||||
` <loc>${escapeXml(`${SITE_ORIGIN}${route.path === '/' ? '/' : route.path}`)}</loc>`,
|
||||
` <lastmod>${lastmod}</lastmod>`,
|
||||
' </url>',
|
||||
].join('\n'),
|
||||
),
|
||||
'</urlset>',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.writeFileSync(path.join(DIST, 'sitemap.xml'), xml, 'utf8');
|
||||
fs.writeFileSync(path.join(DIST, 'robots.txt'), robots, 'utf8');
|
||||
|
||||
console.log(green(`sitemap: ${routes.length} URLs -> ${rel(path.join(DIST, 'sitemap.xml'))}`));
|
||||
console.log(green(`robots: ${rel(path.join(PUBLIC, 'robots.txt'))} and ${rel(path.join(DIST, 'robots.txt'))}`));
|
||||
|
||||
/** Sitemap URLs are XML text; a bare `&` in a query string invalidates the file. */
|
||||
function escapeXml(value) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
Reference in New Issue
Block a user