The shortcuts table in README was hand-maintained and kept drifting from src/lib/shortcuts.ts (the data the in-app help overlay reads). Replace the table with a marker block (<!-- SHORTCUTS:START --> ... <!-- SHORTCUTS:END -->) populated by scripts/gen-readme-shortcuts.mjs. Includes TIPS too, not just shortcuts. Script is plain Node + fs (no tsx/esbuild dep); reads shortcuts.ts as text, strips TS type syntax, dynamic-imports the resulting .mjs. Adds `pnpm gen:readme` script and a `--check` mode that exits 1 on drift (for future CI wiring). Idempotent.
170 lines
6 KiB
JavaScript
170 lines
6 KiB
JavaScript
#!/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 <!-- SHORTCUTS:START --> ... <!-- SHORTCUTS:END -->
|
|
// 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 = "<!-- SHORTCUTS:START -->";
|
|
const END_MARKER = "<!-- SHORTCUTS:END -->";
|
|
|
|
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");
|
|
}
|