Files
PIG-Demo/scripts/new-demo.mjs
T
karti-ai eb88138d15 Frontend: site chrome, demo shell, pages, and the contract gates
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
2026-08-28 16:09:59 -07:00

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);
}