feat(website): asciinema cast pipeline + landing quickstart demo
Settle the cast toolchain (STYLE.md #9) and build the demo pipeline end to end. Driver: autocast, chosen by spike — its !Interactive feeds keys to the running TUI and captures the redraw, the right model for a full-screen crossterm app. asciinema-automation was rejected (assumes shell echo/\n Enter; produced a garbled cast against the TUI). - add asciinema-player; Cast.astro (player island) + Demo.astro (the WASM-swap seam, ADR-website-001 §3) - casts-src/: human-readable command-lists (casts.mjs) + generate.mjs, exposed as `pnpm casts`; expands steps to autocast YAML and records to public/casts/. Command-lists are the durable source; .cast files are regenerable (final re-record sweep due once the app is locked). - quickstart.cast (create -> add columns -> insert -> show data) embedded on the landing page above the feature cards. Verified: pnpm build clean (25 pages); player + cast bundled and served; landing HTML references the cast. Visual playback check pending (no headless browser here — verify via dev server over the tunnel).
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* generate.mjs — turn the cast definitions in `casts.mjs` into asciinema
|
||||
* `.cast` recordings under `public/casts/`, using autocast (ADR-website-001
|
||||
* §2; driver chosen by the 2026-06-10 spike — autocast drives the full-screen
|
||||
* TUI correctly, asciinema-automation does not).
|
||||
*
|
||||
* Usage:
|
||||
* pnpm casts # regenerate every cast
|
||||
* pnpm casts quickstart # regenerate just one
|
||||
*
|
||||
* Requires `autocast` on PATH (cargo install autocast) and a built
|
||||
* `rdbms-playground` binary at ../target/debug (run `cargo build` at the repo
|
||||
* root first). The binary's dir is added to PATH for the autocast child so the
|
||||
* recording shows a clean `$ rdbms-playground` launch line.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import os from 'node:os';
|
||||
import { casts } from './casts.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const websiteRoot = resolve(here, '..');
|
||||
const repoRoot = resolve(websiteRoot, '..');
|
||||
const binDir = resolve(repoRoot, 'target', 'debug');
|
||||
const outDir = resolve(websiteRoot, 'public', 'casts');
|
||||
const cargoBin = resolve(os.homedir(), '.cargo', 'bin');
|
||||
|
||||
/** YAML-escape a single character for a double-quoted scalar. */
|
||||
function charKey(ch) {
|
||||
if (ch === '\\') return '"\\\\"';
|
||||
if (ch === '"') return '"\\""';
|
||||
return `"${ch}"`;
|
||||
}
|
||||
|
||||
/** Named single keys → autocast control codes. */
|
||||
const NAMED_KEYS = {
|
||||
Enter: '^M',
|
||||
Tab: '^I',
|
||||
};
|
||||
|
||||
/** Build the autocast `keys:` list (one entry per line) for a cast's steps. */
|
||||
function keysFor(steps) {
|
||||
const keys = [];
|
||||
for (const step of steps) {
|
||||
if (step.wait != null) {
|
||||
keys.push(`${step.wait}ms`);
|
||||
continue;
|
||||
}
|
||||
if (step.key != null) {
|
||||
const code = NAMED_KEYS[step.key];
|
||||
if (!code) throw new Error(`unknown key: ${step.key}`);
|
||||
keys.push(code);
|
||||
if (step.after != null) keys.push(`${step.after}ms`);
|
||||
continue;
|
||||
}
|
||||
if (step.type != null) {
|
||||
for (const ch of step.type) keys.push(charKey(ch));
|
||||
if (step.enter !== false) keys.push('^M'); // Enter = CR (the TUI submits on \r)
|
||||
if (step.after != null) keys.push(`${step.after}ms`);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unrecognised step: ${JSON.stringify(step)}`);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function yamlFor(cast) {
|
||||
const keys = keysFor(cast.steps)
|
||||
.map((k) => ` - ${k}`)
|
||||
.join('\n');
|
||||
return [
|
||||
'settings:',
|
||||
` width: ${cast.width ?? 90}`,
|
||||
` height: ${cast.height ?? 26}`,
|
||||
` title: ${JSON.stringify(cast.title ?? cast.name)}`,
|
||||
` type_speed: ${cast.typeSpeed ?? '45ms'}`,
|
||||
' timeout: 90s',
|
||||
' prompt: "$ "',
|
||||
'instructions:',
|
||||
' - !Interactive',
|
||||
' command: rdbms-playground',
|
||||
' keys:',
|
||||
keys,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const only = process.argv[2];
|
||||
const selected = only ? casts.filter((c) => c.name === only) : casts;
|
||||
if (only && selected.length === 0) {
|
||||
console.error(`no cast named "${only}". Known: ${casts.map((c) => c.name).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const env = { ...process.env, PATH: `${binDir}:${cargoBin}:${process.env.PATH}` };
|
||||
|
||||
let failures = 0;
|
||||
for (const cast of selected) {
|
||||
const yamlPath = resolve(os.tmpdir(), `autocast-${cast.name}.yaml`);
|
||||
const outPath = resolve(outDir, `${cast.name}.cast`);
|
||||
writeFileSync(yamlPath, yamlFor(cast));
|
||||
console.log(`▶ recording ${cast.name} → public/casts/${cast.name}.cast`);
|
||||
const res = spawnSync('autocast', ['--overwrite', yamlPath, outPath], {
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
rmSync(yamlPath, { force: true });
|
||||
if (res.status !== 0) {
|
||||
console.error(`✗ ${cast.name} failed (autocast exit ${res.status})`);
|
||||
failures += 1;
|
||||
} else {
|
||||
console.log(`✓ ${cast.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures) process.exit(1);
|
||||
console.log(`Done — ${selected.length} cast(s).`);
|
||||
Reference in New Issue
Block a user