| 1 | #!/usr/bin/env node |
| 2 | /** |
| 3 | * render-terminal-capture.mjs — turn real PTY cell captures into what the |
| 4 | * site renders. |
| 5 | * |
| 6 | * Input: web/lib/terminal-captures/*.json, written by |
| 7 | * crates/tui/tests/cucumber/launch_card_pty.rs::website_current_terminal_capture |
| 8 | * (schema in manifest.json: {rows, cols, cells[row][col] -> {x, text, fg, bg, |
| 9 | * flags}}; fg/bg are null, a 0-255 palette index, or [r,g,b]; flags are |
| 10 | * rio-vt style bits). |
| 11 | * |
| 12 | * Output: web/lib/terminal-capture.generated.ts — every frame compacted into |
| 13 | * rows of styled runs (adjacent cells with the same colours and attributes |
| 14 | * merged), plus a style table. components/terminal-capture.tsx renders that |
| 15 | * as live, selectable HTML text. Nothing is retouched: text, colours and |
| 16 | * attributes are the cells the terminal emulator parsed. |
| 17 | * |
| 18 | * node scripts/render-terminal-capture.mjs write the module |
| 19 | * node scripts/render-terminal-capture.mjs --check fail if it is stale |
| 20 | * node scripts/render-terminal-capture.mjs --html out.html [--frame home] |
| 21 | * write a standalone page of one frame (used to rasterize the README |
| 22 | * image from the same capture; see --png) |
| 23 | * node scripts/render-terminal-capture.mjs --png out.png [--frame home] |
| 24 | * rasterize that page with Playwright (PLAYWRIGHT_MODULE may point at |
| 25 | * a local playwright install; launched with the "chrome" channel) |
| 26 | * node scripts/render-terminal-capture.mjs --svg out.svg [--frame home] |
| 27 | * export the same native cells without a browser or a screenshot tool |
| 28 | */ |
| 29 | import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 30 | import os from "node:os"; |
| 31 | import path from "node:path"; |
| 32 | import { fileURLToPath, pathToFileURL } from "node:url"; |
| 33 | |
| 34 | const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 35 | const captureDir = path.join(webRoot, "lib", "terminal-captures"); |
| 36 | const outPath = path.join(webRoot, "lib", "terminal-capture.generated.ts"); |
| 37 | |
| 38 | // rio-vt crosswords::style::StyleFlags bits. |
| 39 | const INVERSE = 1 << 0; |
| 40 | const BOLD = 1 << 1; |
| 41 | const ITALIC = 1 << 2; |
| 42 | const DIM = 1 << 3; |
| 43 | const HIDDEN = 1 << 4; |
| 44 | const STRIKEOUT = 1 << 5; |
| 45 | const ANY_UNDERLINE = (1 << 6) | (1 << 7) | (1 << 8) | (1 << 9) | (1 << 10); |
| 46 | |
| 47 | // Terminal defaults for cells that carry no colour of their own. The captured |
| 48 | // frames paint every visible glyph explicitly, so these only reach blanks. |
| 49 | const DEFAULT_FG = "#f6f2e8"; |
| 50 | const DEFAULT_BG = "#0a1e33"; |
| 51 | |
| 52 | // xterm 256-colour palette. |
| 53 | const ANSI16 = [ |
| 54 | "#000000", "#cd0000", "#00cd00", "#cdcd00", "#0000ee", "#cd00cd", "#00cdcd", "#e5e5e5", |
| 55 | "#7f7f7f", "#ff0000", "#00ff00", "#ffff00", "#5c5cff", "#ff00ff", "#00ffff", "#ffffff", |
| 56 | ]; |
| 57 | function hex(r, g, b) { |
| 58 | return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`; |
| 59 | } |
| 60 | function paletteColor(index) { |
| 61 | if (index < 16) return ANSI16[index]; |
| 62 | if (index < 232) { |
| 63 | const i = index - 16; |
| 64 | const level = (v) => (v === 0 ? 0 : 55 + v * 40); |
| 65 | return hex(level(Math.floor(i / 36)), level(Math.floor(i / 6) % 6), level(i % 6)); |
| 66 | } |
| 67 | const v = 8 + (index - 232) * 10; |
| 68 | return hex(v, v, v); |
| 69 | } |
| 70 | function color(value, fallback) { |
| 71 | if (value === null || value === undefined) return fallback; |
| 72 | if (typeof value === "number") return paletteColor(value); |
| 73 | return hex(...value); |
| 74 | } |
| 75 | |
| 76 | /** The frames the site ships, keyed by a short id. */ |
| 77 | function frames() { |
| 78 | const manifest = JSON.parse(readFileSync(path.join(captureDir, "manifest.json"), "utf8")); |
| 79 | return manifest.frames.map((frame) => ({ |
| 80 | id: frame.file.replace(/^website-/, "").replace(/-\d+x\d+\.json$/, ""), |
| 81 | file: frame.file, |
| 82 | state: frame.state, |
| 83 | manifest, |
| 84 | })); |
| 85 | } |
| 86 | |
| 87 | function compact(capture) { |
| 88 | const styles = []; |
| 89 | const styleIndex = new Map(); |
| 90 | const rows = capture.cells.map((row) => { |
| 91 | const runs = []; |
| 92 | for (const cell of row) { |
| 93 | const flags = cell.flags ?? 0; |
| 94 | let fg = color(cell.fg, DEFAULT_FG); |
| 95 | let bg = color(cell.bg, DEFAULT_BG); |
| 96 | if (flags & INVERSE) [fg, bg] = [bg, fg]; |
| 97 | const style = { |
| 98 | fg, |
| 99 | bg, |
| 100 | ...(flags & BOLD ? { bold: true } : {}), |
| 101 | ...(flags & ITALIC ? { italic: true } : {}), |
| 102 | ...(flags & DIM ? { dim: true } : {}), |
| 103 | ...(flags & ANY_UNDERLINE ? { underline: true } : {}), |
| 104 | ...(flags & STRIKEOUT ? { strike: true } : {}), |
| 105 | }; |
| 106 | // A blank cell only shows its background; do not split runs on the |
| 107 | // attributes of a space. |
| 108 | const text = flags & HIDDEN || cell.text === "" ? " " : cell.text; |
| 109 | const key = text === " " ? `${bg}` : JSON.stringify(style); |
| 110 | const last = runs.at(-1); |
| 111 | if (last && (last.key === key || (text === " " && last.bg === bg))) { |
| 112 | last.text += text; |
| 113 | continue; |
| 114 | } |
| 115 | runs.push({ key, bg, style: text === " " ? { fg: DEFAULT_FG, bg } : style, text }); |
| 116 | } |
| 117 | return runs.map((run) => { |
| 118 | const id = JSON.stringify(run.style); |
| 119 | if (!styleIndex.has(id)) { |
| 120 | styleIndex.set(id, styles.length); |
| 121 | styles.push(run.style); |
| 122 | } |
| 123 | return [run.text, styleIndex.get(id)]; |
| 124 | }); |
| 125 | }); |
| 126 | return { styles, rows }; |
| 127 | } |
| 128 | |
| 129 | function generate() { |
| 130 | const all = frames(); |
| 131 | const { manifest } = all[0]; |
| 132 | const out = {}; |
| 133 | for (const frame of all) { |
| 134 | const capture = JSON.parse(readFileSync(path.join(captureDir, frame.file), "utf8")); |
| 135 | const { styles, rows } = compact(capture); |
| 136 | out[frame.id] = { |
| 137 | file: `web/lib/terminal-captures/${frame.file}`, |
| 138 | state: frame.state, |
| 139 | cols: capture.cols, |
| 140 | rows: capture.rows, |
| 141 | styles, |
| 142 | lines: rows, |
| 143 | }; |
| 144 | } |
| 145 | const header = `// GENERATED by web/scripts/render-terminal-capture.mjs — do not edit. |
| 146 | // Source: web/lib/terminal-captures/*.json, real PTY cell captures from |
| 147 | // ${manifest.source} |
| 148 | // (binary ${manifest.binaryVersion}, base commit ${manifest.baseCommit}, captured ${manifest.capturedAt}). |
| 149 | `; |
| 150 | const meta = { |
| 151 | source: manifest.source, |
| 152 | binaryVersion: manifest.binaryVersion, |
| 153 | baseCommit: manifest.baseCommit, |
| 154 | capturedAt: manifest.capturedAt, |
| 155 | conditions: manifest.conditions, |
| 156 | }; |
| 157 | return `${header} |
| 158 | export type TerminalCaptureStyle = { |
| 159 | fg: string; |
| 160 | bg: string; |
| 161 | bold?: boolean; |
| 162 | italic?: boolean; |
| 163 | dim?: boolean; |
| 164 | underline?: boolean; |
| 165 | strike?: boolean; |
| 166 | }; |
| 167 | |
| 168 | export type TerminalCaptureFrame = { |
| 169 | file: string; |
| 170 | state: string; |
| 171 | cols: number; |
| 172 | rows: number; |
| 173 | styles: TerminalCaptureStyle[]; |
| 174 | /** Each row is a list of [text, style index] runs. */ |
| 175 | lines: [string, number][][]; |
| 176 | }; |
| 177 | |
| 178 | export const TERMINAL_CAPTURE_META = ${JSON.stringify(meta, null, 2)} as const; |
| 179 | |
| 180 | export const TERMINAL_CAPTURE_FRAMES = ${JSON.stringify(out)} as const satisfies Record<string, TerminalCaptureFrame>; |
| 181 | |
| 182 | export type TerminalCaptureFrameId = keyof typeof TERMINAL_CAPTURE_FRAMES; |
| 183 | `; |
| 184 | } |
| 185 | |
| 186 | function escapeHtml(text) { |
| 187 | return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); |
| 188 | } |
| 189 | |
| 190 | /** A standalone page of one frame, drawn with the same rules as the component. */ |
| 191 | function frameHtml(id) { |
| 192 | const all = frames(); |
| 193 | const frame = all.find((f) => f.id === id); |
| 194 | if (!frame) throw new Error(`unknown frame ${id}; known: ${all.map((f) => f.id).join(", ")}`); |
| 195 | const capture = JSON.parse(readFileSync(path.join(captureDir, frame.file), "utf8")); |
| 196 | const { styles, rows } = compact(capture); |
| 197 | const body = rows |
| 198 | .map( |
| 199 | (runs) => |
| 200 | runs |
| 201 | .map(([text, index]) => { |
| 202 | const s = styles[index]; |
| 203 | const css = [ |
| 204 | `color:${s.fg}`, |
| 205 | `background:${s.bg}`, |
| 206 | s.bold ? "font-weight:700" : "", |
| 207 | s.italic ? "font-style:italic" : "", |
| 208 | s.dim ? "opacity:.6" : "", |
| 209 | s.underline ? "text-decoration:underline" : "", |
| 210 | ] |
| 211 | .filter(Boolean) |
| 212 | .join(";"); |
| 213 | const inner = [...text] |
| 214 | .map((ch) => (/[\u2800-\u28ff]/.test(ch) ? `<span class="c">${ch}</span>` : escapeHtml(ch))) |
| 215 | .join(""); |
| 216 | return `<span style="${css}">${inner}</span>`; |
| 217 | }) |
| 218 | .join(""), |
| 219 | ) |
| 220 | .map((row) => `<span class="r">${row}</span>`) |
| 221 | .join(""); |
| 222 | const top = styles[rows[0][0][1]].bg; |
| 223 | const bottom = styles[rows.at(-1)[0][1]].bg; |
| 224 | return `<!doctype html><meta charset="utf-8"><title>${escapeHtml(frame.state)}</title> |
| 225 | <style> |
| 226 | html,body{margin:0;background:${DEFAULT_BG}} |
| 227 | pre{margin:0;padding:14px 16px;display:inline-block;font:15px/1.25 Menlo,"SF Mono",ui-monospace,monospace;white-space:pre;background:linear-gradient(${top},${bottom});font-variant-ligatures:none} |
| 228 | .r{display:block;height:1.25em}.r>span{display:inline-block;height:100%;vertical-align:top} |
| 229 | .c{display:inline-block;width:1ch;overflow:hidden;vertical-align:top} |
| 230 | </style> |
| 231 | <pre>${body}</pre>`; |
| 232 | } |
| 233 | |
| 234 | const args = process.argv.slice(2); |
| 235 | const frameArg = args.includes("--frame") ? args[args.indexOf("--frame") + 1] : "home"; |
| 236 | |
| 237 | /** Native cell geometry, colours and glyphs, suitable for PNG rasterization. */ |
| 238 | function frameSvg(id) { |
| 239 | const frame = frames().find((item) => item.id === id); |
| 240 | if (!frame) throw new Error(`unknown frame ${id}`); |
| 241 | const capture = JSON.parse(readFileSync(path.join(captureDir, frame.file), "utf8")); |
| 242 | const { styles, rows } = compact(capture); |
| 243 | const shapes = []; |
| 244 | rows.forEach((runs, row) => { |
| 245 | let column = 0; |
| 246 | for (const [text, index] of runs) { |
| 247 | const style = styles[index]; |
| 248 | const glyphs = [...text]; |
| 249 | shapes.push(`<rect x="${column * 10}" y="${row * 20}" width="${glyphs.length * 10}" height="20" fill="${style.bg}"/>`); |
| 250 | for (const glyph of glyphs) { |
| 251 | const codepoint = glyph.codePointAt(0); |
| 252 | if (codepoint >= 0x2800 && codepoint <= 0x28ff) { |
| 253 | // Braille is a native eight-dot bitmap. Draw its actual set bits |
| 254 | // rather than depending on a rasterizer's missing-font fallback. |
| 255 | const dots = [[2.75, 3], [2.75, 7.5], [2.75, 12], [7.25, 3], [7.25, 7.5], [7.25, 12], [2.75, 16.5], [7.25, 16.5]]; |
| 256 | dots.forEach(([x, y], bit) => { |
| 257 | if ((codepoint - 0x2800) & (1 << bit)) shapes.push(`<circle cx="${column * 10 + x}" cy="${row * 20 + y}" r="1.4" fill="${style.fg}"${style.dim ? ' opacity="0.6"' : ''}/>`); |
| 258 | }); |
| 259 | } else if (glyph !== " ") { |
| 260 | const attributes = [ |
| 261 | style.bold ? 'font-weight="700"' : '', |
| 262 | style.italic ? 'font-style="italic"' : '', |
| 263 | style.dim ? 'opacity="0.6"' : '', |
| 264 | style.underline || style.strike ? `text-decoration="${[style.underline ? 'underline' : '', style.strike ? 'line-through' : ''].filter(Boolean).join(' ')}"` : '', |
| 265 | ].filter(Boolean).join(' '); |
| 266 | shapes.push(`<text x="${column * 10 + 5}" y="${row * 20 + 15}" fill="${style.fg}" ${attributes}>${escapeHtml(glyph)}</text>`); |
| 267 | } |
| 268 | column += 1; |
| 269 | } |
| 270 | } |
| 271 | }); |
| 272 | return `<svg xmlns="http://www.w3.org/2000/svg" width="${capture.cols * 10}" height="${capture.rows * 20}" viewBox="0 0 ${capture.cols * 10} ${capture.rows * 20}" font-family="Menlo,DejaVu Sans Mono,monospace" font-size="16" text-anchor="middle"><title>${escapeHtml(frame.state)}</title>${shapes.join('')}</svg>\n`; |
| 273 | } |
| 274 | |
| 275 | if (args.includes("--svg")) { |
| 276 | const target = args[args.indexOf("--svg") + 1]; |
| 277 | writeFileSync(target, frameSvg(frameArg)); |
| 278 | console.log(`wrote ${target}`); |
| 279 | } else if (args.includes("--html")) { |
| 280 | const target = args[args.indexOf("--html") + 1]; |
| 281 | writeFileSync(target, frameHtml(frameArg)); |
| 282 | console.log(`wrote ${target}`); |
| 283 | } else if (args.includes("--png")) { |
| 284 | const target = path.resolve(args[args.indexOf("--png") + 1]); |
| 285 | const tmp = mkdtempSync(path.join(os.tmpdir(), "terminal-capture-")); |
| 286 | const html = path.join(tmp, "frame.html"); |
| 287 | writeFileSync(html, frameHtml(frameArg)); |
| 288 | const spec = process.env.PLAYWRIGHT_MODULE; |
| 289 | const mod = await import(spec ? pathToFileURL(path.join(spec, "index.mjs")).href : "playwright"); |
| 290 | const chromium = mod.chromium ?? mod.default.chromium; |
| 291 | const browser = await chromium.launch({ channel: "chrome" }); |
| 292 | const page = await browser.newPage({ deviceScaleFactor: 2 }); |
| 293 | await page.goto(pathToFileURL(html).href); |
| 294 | await page.locator("pre").screenshot({ path: target }); |
| 295 | await browser.close(); |
| 296 | rmSync(tmp, { recursive: true, force: true }); |
| 297 | console.log(`wrote ${target}`); |
| 298 | } else { |
| 299 | const next = generate(); |
| 300 | if (args.includes("--check")) { |
| 301 | let current = ""; |
| 302 | try { |
| 303 | current = readFileSync(outPath, "utf8"); |
| 304 | } catch { |
| 305 | // Missing counts as stale. |
| 306 | } |
| 307 | if (current !== next) { |
| 308 | console.error("terminal-capture.generated.ts is stale: run node scripts/render-terminal-capture.mjs"); |
| 309 | process.exit(1); |
| 310 | } |
| 311 | console.log("terminal-capture.generated.ts is current"); |
| 312 | } else { |
| 313 | writeFileSync(outPath, next); |
| 314 | console.log(`wrote ${path.relative(webRoot, outPath)}`); |
| 315 | } |
| 316 | } |
| 317 |