a8f84c9d17
Address cast review feedback: - Trim every cast to the in-app region (generate.mjs): the recording now starts with the app already running and ends on the last in-app frame — drops the `$ rdbms-playground` launch and the return-to-shell frame (the latter was the stray cursor-under-$ artifact). Opt out per cast with `keepShell: true` for demos that document the CLI launch. - Landing quickstart cast: autoPlay + loop, with a 2.5s hold on the final frame so it pauses before restarting. - Cap the demo at max-width 46rem and centre it, so the player (fit:'width') no longer scales its font up to the full splash column. Casts re-recorded via `pnpm casts`. Build clean (25 pages). Tab-keypress visibility deferred to an in-app overlay primitive (filed as issue #22 — also serves the planned guided-lesson system); the cast notes Tab in its caption for now.
165 lines
5.9 KiB
JavaScript
165 lines
5.9 KiB
JavaScript
#!/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, readFileSync, 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;
|
|
}
|
|
|
|
/**
|
|
* Trim a recorded cast to just the in-app portion and (optionally) hold the
|
|
* final frame. The app enters the alternate screen (`?1049h`) right after
|
|
* launch and leaves it (`?1049l`) on quit, so keeping the events between those
|
|
* drops the shell prompt, the `$ rdbms-playground` launch line, and the
|
|
* return-to-shell frame — the cast starts with the app already running and
|
|
* ends on the last in-app frame (no stray cursor-under-prompt frame).
|
|
* `holdEnd` re-emits the final frame after a pause so a looping cast lingers
|
|
* before restarting.
|
|
*/
|
|
function trimCast(outPath, { holdEnd = 1.5 } = {}) {
|
|
const lines = readFileSync(outPath, 'utf8').split('\n').filter(Boolean);
|
|
const header = JSON.parse(lines[0]);
|
|
const events = lines.slice(1).map((l) => JSON.parse(l));
|
|
const isOut = (e) => Array.isArray(e) && e[1] === 'o' && typeof e[2] === 'string';
|
|
|
|
const start = events.findIndex((e) => isOut(e) && e[2].includes('?1049h'));
|
|
let end = -1;
|
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
if (isOut(events[i]) && events[i][2].includes('?1049l')) {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
const kept = events.slice(start < 0 ? 0 : start, end < 0 ? events.length : end);
|
|
if (kept.length === 0) return; // nothing matched — leave the cast untouched
|
|
const t0 = kept[0][0];
|
|
const rebased = kept.map((e) => [Number((e[0] - t0).toFixed(6)), e[1], e[2]]);
|
|
if (holdEnd > 0) {
|
|
const lastT = rebased[rebased.length - 1][0];
|
|
rebased.push([Number((lastT + holdEnd).toFixed(6)), 'o', '']); // hold final frame
|
|
}
|
|
delete header.duration; // let the player recompute from events
|
|
writeFileSync(
|
|
outPath,
|
|
[JSON.stringify(header), ...rebased.map((e) => JSON.stringify(e))].join('\n') + '\n'
|
|
);
|
|
}
|
|
|
|
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 {
|
|
// Trim the shell launch/quit so the cast starts with the app already
|
|
// running (the default; opt out with `keepShell: true` for casts that
|
|
// deliberately document the CLI launch).
|
|
if (!cast.keepShell) trimCast(outPath, { holdEnd: cast.holdEnd ?? 1.5 });
|
|
console.log(`✓ ${cast.name}`);
|
|
}
|
|
}
|
|
|
|
if (failures) process.exit(1);
|
|
console.log(`Done — ${selected.length} cast(s).`);
|