eb88138d15
Five parallel lanes plus an integration pass. The header, gallery, router and sitemap are all generated from the demo registry, so adding src/demos/<slug>/ puts a demo everywhere with zero edits to shared files — which is the whole reason demo nine cannot break demo one. check-demos enforces the twelve contract rules: 142 checks over one live demo. Two worth naming. The shell may not mention a specific slug, because an 'if (slug === wordle)' in src/components/demo/ is a contract bug wearing a patch. And a spec-status demo must ship a real specification — task, actions, grader, counterweight, eval command — since a coming-soon card reads worse than an honest empty gallery. Bundle budget holds: entry 108.79 kB gzipped against a 160 kB ceiling, the demo chunk 21.15 kB against 90 kB. recharts is 108 kB gzipped and lives behind a lazy import so it never touches the entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
199 lines
7.6 KiB
JavaScript
199 lines
7.6 KiB
JavaScript
#!/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.',
|
|
);
|
|
}
|
|
// An empty template directory would copy cleanly, report "Created 0 files"
|
|
// and leave behind a demo that the registry quarantines for a reason nobody
|
|
// connects back to this command.
|
|
if (countFiles(job.from) === 0) {
|
|
fail(`${rel(job.from)} is empty.`, 'There is nothing to scaffold from. Fill in the template first.');
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------------------------------- 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 */
|
|
|
|
/** Files in a tree, recursively. Used only to reject an empty template. */
|
|
function countFiles(dir) {
|
|
return fs.readdirSync(dir, { withFileTypes: true }).reduce((total, entry) => {
|
|
if (entry.name === '__pycache__' || entry.name === 'node_modules') return total;
|
|
if (entry.isDirectory()) return total + countFiles(path.join(dir, entry.name));
|
|
return total + (entry.isFile() ? 1 : 0);
|
|
}, 0);
|
|
}
|
|
|
|
function capitalise(word) {
|
|
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
}
|
|
|
|
/** Applies the substitution table. No token is a prefix of another, so order
|
|
* does not matter here — but keep it that way if you add one. */
|
|
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);
|
|
}
|