返回 CodeWhale
render-terminal-capture.mjs
根目录 / web / scripts / render-terminal-capture.mjs
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
317 lines Plain Text