#!/usr/bin/env node
// from-docs — turn a fuser docs page into a screenplay draft.
//
// node bin/from-docs.mjs apps/quickstart
//
// The docs page IS the script. This is the whole point: a tutorial written
// separately from the docs drifts from the docs, and then you have two truths.
// Derive the narration from the page and the video can be re-rendered whenever
// the page changes — the words on screen and the words in your ear stay the
// same words.
//
// What it can and cannot do:
//
// CAN — lift the prose, clean it for speech, and cut it into beats at the
// page's own `##` headings. Prose is already ordered for a reader, and
// that order is almost always the right order for a viewer.
// CAN — find every  and hand you the alt text. Those images
// mark precisely the moments the page's author thought needed a
// picture, and the alt text describes what should be on screen. They
// become the `do:` stubs.
// CANNOT — know which button to click. Prose says "open focus mode"; only a
// selector can say HOW. You fill those in (vault/fuser/skills/drive-ui.md
// has the map), and that is the real authoring work.
//
// The output is a draft, not a deliverable. Read every `say:` aloud before you
// render — writing meant for the eye often needs a comma the ear can hear.
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname, join, resolve, basename } from "node:path";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const FUSER = process.env.FUSER_REPO || `${process.env.HOME}/Developer/fuser`;
const DOCS = join(FUSER, "apps", "docs", "content", "docs");
/// Strip a line of MDX down to something a voice can say.
function speakable(md) {
return md
// Self-closing JSX ().
// Recover the key names — "press CMD Enter" is the instruction; "press" alone
// is useless narration.
.replace(/]*modifiers=\{\[([^\]]*)\]\}[^>]*keyCharacter="([^"]+)"[^>]*\/>/g,
(_, mods, key) => `${mods.replace(/['"]/g, "").split(",").join(" ")} ${key}`.trim())
.replace(/<[^>]+\/>/g, "") // any other self-closing component
.replace(/<\/?[A-Za-z][^>]*>/g, "") // component open/close tags, keep inner text
.replace(/!\[[^\]]*\]\([^)]*\)/g, "") // images (captured separately)
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // links → their text
.replace(/\*\*([^*]+)\*\*/g, "$1") // bold
.replace(/_([^_]+)_/g, "$1") // italic
.replace(/`([^`]+)`/g, "$1") // code
.replace(/\s+/g, " ")
.trim();
}
const page = process.argv[2];
if (!page) {
console.error("usage: from-docs e.g. apps/quickstart");
process.exit(1);
}
const src = join(DOCS, `${page}.mdx`);
if (!existsSync(src)) {
console.error(`no such page: ${src}`);
process.exit(1);
}
const raw = readFileSync(src, "utf8");
// Frontmatter → title/description.
const fm = raw.match(/^---\n([\s\S]*?)\n---\n/);
const meta = {};
if (fm) {
for (const line of fm[1].split("\n")) {
const m = line.match(/^(\w+):\s*(.*)$/);
if (m) meta[m[1]] = m[2].replace(/^["']|["']$/g, "");
}
}
let body = raw.slice(fm ? fm[0].length : 0)
.replace(/^import .*$/gm, "") // MDX imports are not narration
.replace(/```[\s\S]*?```/g, ""); // code fences are shown, not spoken
// Split at `##` headings. Everything before the first one is the intro.
const parts = body.split(/^##\s+(.+)$/m);
const sections = [{ heading: null, text: parts[0] }];
for (let i = 1; i < parts.length; i += 2) {
sections.push({ heading: parts[i].trim(), text: parts[i + 1] || "" });
}
const beats = [];
for (const sec of sections) {
// The screenshots the page's author placed — these are the visual cues.
const shots = [...sec.text.matchAll(/!\[([^\]]*)\]\(([^)]*)\)/g)].map((m) => ({
alt: m[1], src: m[2],
}));
const prose = sec.text
.split(/\n\s*\n/) // paragraphs
.map(speakable)
.filter((p) => p.length > 24); // drop scraps and stray punctuation
for (const [i, p] of prose.entries()) {
beats.push({
say: p,
heading: sec.heading,
// Attach a section's screenshots to its first beat — that is where the
// page shows the picture, so that is where the video should be looking.
hint: i === 0 ? shots.map((s) => s.alt).filter(Boolean) : [],
});
}
}
const slug = page.replace(/\//g, "-");
const outDir = join(HERE, "..", "screenplays");
mkdirSync(outDir, { recursive: true });
const out = join(outDir, `${slug}.mjs`);
const body_ = beats.map((b) => {
const head = b.heading ? ` // ── ${b.heading}\n` : "";
const hint = b.hint.length
? b.hint.map((h) => ` // on screen (from the page's own screenshot): ${h}\n`).join("")
: "";
return `${head}${hint} {
say: ${JSON.stringify(b.say)},
// TODO blocking — see vault/fuser/skills/drive-ui.md for the selector map.
do: async ({ cdp, click, point, type }) => {},
},`;
}).join("\n");
writeFileSync(out, `// ${slug} — DRAFT, generated from apps/docs/content/docs/${page}.mdx
//
// The narration below is the docs page's own prose. The blocking is not written
// yet: every \`do:\` is an empty stub. Fill them in, then:
//
// node captutor.mjs narrate ${slug} # hear the pacing before you film
// node captutor.mjs render ${slug}
//
// Re-running from-docs OVERWRITES this file — copy it aside once you have done
// the blocking, or teach from-docs to preserve it.
export default {
slug: ${JSON.stringify(slug)},
title: ${JSON.stringify(meta.title || slug)},
voice: "jeffrey",
window: "Chrome",
match: "localhost:3200",
fps: 60,
// Sign in / seed / open the right project here — setup runs BEFORE recording,
// so none of it lands in the tutorial.
setup: async ({ cdp }) => {
await cdp.nav("http://localhost:3200/");
},
beats: [
${body_}
],
};
`);
console.log(`→ ${out}`);
console.log(` ${beats.length} beats from ${sections.length} sections`);
const withShots = beats.filter((b) => b.hint.length).length;
console.log(` ${withShots} beat(s) carry a screenshot hint from the page`);
console.log(`\nnext: fill in the \`do:\` stubs, then \`node captutor.mjs narrate ${slug}\``);