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:
karti-ai
2026-08-28 15:53:25 -07:00
parent 2c2dcad9fd
commit b601511e7f
38 changed files with 3419 additions and 646 deletions
+182
View File
@@ -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);
}