#!/usr/bin/env node /** * `node scripts/new-demo.mjs ` — scaffold a demo. * * Copies `src/demos/_template` to `src/demos/` and `envs/_template` to * `envs/`, 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 \n'); console.log(' kebab-case, e.g. support-refund-triage. Becomes the directory name,'); console.log(' meta.slug and the URL /demos/.'); 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); }