#!/usr/bin/env node // gen-readme-shortcuts.mjs — regenerate README.md's shortcut + tips section // from src/lib/shortcuts.ts (the single source of truth used by the in-app // help overlay). // // Usage: // node scripts/gen-readme-shortcuts.mjs # rewrite README // node scripts/gen-readme-shortcuts.mjs --check # exit 1 if README would change // // To extend: add or edit entries in src/lib/shortcuts.ts, then run this // script. The README marker block ... // is replaced atomically; the rest of the README is left alone. import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(HERE, ".."); const SHORTCUTS_TS = join(REPO_ROOT, "src", "lib", "shortcuts.ts"); const README_PATH = join(REPO_ROOT, "README.md"); const START_MARKER = ""; const END_MARKER = ""; const CHECK_MODE = process.argv.includes("--check"); // ---------------------------------------------------------------------------- // Load shortcuts.ts as data. The file is pure data exports — no React, no // runtime imports — so we can strip TypeScript-only syntax with regex, drop // the result into a temp .mjs file, and dynamically import it. Cheaper than // pulling in tsx/esbuild as a devDep just for this one script. // ---------------------------------------------------------------------------- async function loadShortcutsModule() { const src = await readFile(SHORTCUTS_TS, "utf8"); // Strip `export interface { ... }` blocks (handles nested braces by // walking; the file has flat interfaces today so a brace-counter is enough). const stripped = stripInterfaceDecls(src) // Drop `: TypeAnnotation` on the export declarations // (e.g. `export const SHORTCUT_SECTIONS: ShortcutSection[] = [...]`). .replace(/^(export\s+const\s+\w+)\s*:\s*[^=]+?=/gm, "$1 ="); const dir = await mkdtemp(join(tmpdir(), "tiletopia-genreadme-")); const tmpFile = join(dir, "shortcuts.mjs"); try { await writeFile(tmpFile, stripped, "utf8"); return await import(pathToFileURL(tmpFile).href); } finally { await rm(dir, { recursive: true, force: true }); } } function stripInterfaceDecls(src) { let out = ""; let i = 0; while (i < src.length) { const match = src.slice(i).match(/^export\s+interface\s+\w+\s*\{/m); if (!match) { out += src.slice(i); break; } const localStart = src.indexOf(match[0], i); out += src.slice(i, localStart); // Walk braces to find the end of the interface block. let depth = 0; let j = localStart + match[0].length - 1; // points at the opening `{` for (; j < src.length; j++) { const c = src[j]; if (c === "{") depth++; else if (c === "}") { depth--; if (depth === 0) { j++; break; } } } i = j; } return out; } // ---------------------------------------------------------------------------- // Render the markdown block. Mirrors the README's existing table style: // - 2-column `| Key | Action |` table // - keys wrapped in backticks // - description in plain prose // Tips render as a `#### Title` heading plus a paragraph. // ---------------------------------------------------------------------------- function renderBlock({ SHORTCUT_SECTIONS, TIPS }) { const lines = []; lines.push(""); lines.push("#### Keyboard shortcuts"); lines.push(""); for (const section of SHORTCUT_SECTIONS) { lines.push(`**${section.title}**`); lines.push(""); lines.push("| Key | Action |"); lines.push("|---|---|"); for (const item of section.items) { lines.push(`| \`${escapeCell(item.keys)}\` | ${escapeCell(item.description)} |`); } lines.push(""); } lines.push("#### Tips"); lines.push(""); for (const tip of TIPS) { lines.push(`- **${escapeInline(tip.title)}** — ${escapeInline(tip.body)}`); } lines.push(""); return lines.join("\n"); } // Cell values must not contain raw pipes (would break the table) or newlines. function escapeCell(s) { return s.replace(/\|/g, "\\|").replace(/\n/g, " "); } // Body text gets newlines collapsed but pipes kept (lists, not tables). function escapeInline(s) { return s.replace(/\n/g, " "); } // ---------------------------------------------------------------------------- // Splice the generated block into the README between the markers. // ---------------------------------------------------------------------------- function spliceReadme(readme, block) { const startIdx = readme.indexOf(START_MARKER); const endIdx = readme.indexOf(END_MARKER); if (startIdx === -1 || endIdx === -1) { throw new Error( `README.md is missing one of the markers (${START_MARKER} / ${END_MARKER}). ` + "Add them around the section you want regenerated.", ); } if (endIdx < startIdx) { throw new Error(`${END_MARKER} appears before ${START_MARKER} in README.md`); } const before = readme.slice(0, startIdx + START_MARKER.length); const after = readme.slice(endIdx); return `${before}\n${block}\n${after}`; } // ---------------------------------------------------------------------------- // Main. // ---------------------------------------------------------------------------- const mod = await loadShortcutsModule(); const block = renderBlock(mod); const readme = await readFile(README_PATH, "utf8"); const next = spliceReadme(readme, block); if (CHECK_MODE) { if (next !== readme) { process.stderr.write( "README.md is out of sync with src/lib/shortcuts.ts. " + "Run `pnpm gen:readme` to regenerate.\n", ); process.exit(1); } process.stdout.write("README.md is in sync with src/lib/shortcuts.ts.\n"); process.exit(0); } if (next === readme) { process.stdout.write("README.md already up to date.\n"); } else { await writeFile(README_PATH, next, "utf8"); process.stdout.write("README.md regenerated.\n"); }