Skill Tracer
Skills › Visual Explainer › visual-explainer › render.mjs

render.mjs

277 lines · plugins/visual-explainer/kit/render.mjs at 9d5f880

1#!/usr/bin/env node
2// Turns a page source into one self-contained page. A page that links the kit (<link rel="stylesheet" data-ve-kit>)
3// gets the kit's CSS, fonts, SVG defs, and script inlined at that marker; a plan source goes to plan/render.mjs.
4// Only the marker elements change, so a rendered page renders to itself. No installed dependencies (Pele is vendored), so it runs
5// from a copied skill folder.
6import { lstatSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
7import { spawn } from "node:child_process";
8import { basename, dirname, join, resolve, sep } from "node:path";
9import { fileURLToPath, pathToFileURL } from "node:url";
10import { decode, esc, find, hasClass, inner, kids, parseHtml, serialize, textOf, words } from "./html.mjs";
11import { expandFlow } from "./flow.mjs";
12import { plural } from "./machine.mjs";
13import { expandAsk } from "./ask.mjs";
14import { TAGS } from "./tags.mjs";
15import { PeleError, render as drawMermaid } from "./vendor/pele.mjs";
16import { hasPlan, renderPlan, renderPlanForHost } from "../plan/render.mjs";
18const here = dirname(fileURLToPath(import.meta.url));
20// Each register's families, at the weights the kit uses.
21const FONTS = {
22 instrument: "family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600",
23 blueprint: "family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500;600",
24 paper: "family=Newsreader:opsz,wght@6..72,400..600&family=Atkinson+Hyperlegible+Next:wght@400;600&family=Atkinson+Hyperlegible+Mono:wght@400;500;600",
25 editorial: "family=Instrument+Serif&family=Instrument+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600",
26};
28/** Figures, figures without a caption, expanded tags and flows, prose words outside figures, and a line per walked machine. */
29function count(doc, tags) {
30 const r = { figures: 0, uncaptioned: 0, dataTags: tags.filter((t) => t.name !== "ve-flow").length, flows: tags.filter((t) => t.name === "ve-flow").length, proseWords: 0 };
31 const machines = tags.filter((t) => t.machine).map((t) => t.machine.line);
32 if (machines.length) r.machines = machines;
33 // a <p> inside an <li> is counted once, with its <li>
34 const visit = (n, quiet) => {
35 for (const c of kids(n)) {
36 if (c.name === "figure") { r.figures++; if (!kids(c).some((k) => k.name === "figcaption")) r.uncaptioned++; }
37 const prose = !quiet && (c.name === "p" || c.name === "li");
38 if (prose) r.proseWords += words(textOf(c));
39 visit(c, quiet || prose || c.name === "figure");
40 }
41 };
42 visit(doc, false);
43 return r;
46export const reportPage = (html) => count(parseHtml(html), []);
48export const reportLine = (r) => `Page: ${plural(r.figures, "figure")}${r.uncaptioned ? ` (${r.uncaptioned} without a caption)` : ""}${r.dataTags ? ` · ${plural(r.dataTags, "data tag")}` : ""}${r.flows ? ` · ${plural(r.flows, "flow")}` : ""} · ${plural(r.proseWords, "prose word")} outside figures${(r.machines ?? []).map((m) => `\n${m}`).join("")}`;
50/**
51 * Inlines the kit at its marker and expands the data tags, <ve-flow>, and <ve-ask>. A page with none returns unchanged, with `kit: false`.
52 * `sendBack` marks a page Pi opens in Glimpse, so the reader's reply goes to the agent instead of being copied.
53 * `root` is the repo a <ve-flow> model imports from; `models: false` refuses to run one (the MCP server, unless opted in).
54 */
55export function renderPage(source, { sendBack = false, root = process.cwd(), models = true } = {}) {
56 const errors = [], warnings = [];
57 const doc = parseHtml(source);
58 const links = find(doc, (n) => n.name === "link" && "data-ve-kit" in n.attrs);
59 const tags = find(doc, (n) => Object.hasOwn(TAGS, n.name) || n.name === "ve-flow" || n.name === "ve-ask");
60 const asks = tags.filter((t) => t.name === "ve-ask"), charts = tags.filter((t) => t.name !== "ve-ask");
61 // Mermaid is drawn on kit pages only; a deck or another page without the kit keeps its <pre> as written
62 const mermaids = links.length ? find(doc, (n) => n.name === "pre" && hasClass(n, "mermaid")) : [];
63 if (!links.length && !tags.length && !mermaids.length) return { html: source, kit: false, errors, warnings, report: count(doc, charts) };
64 const lineOf = (pos) => source.slice(0, pos).split("\n").length;
65 const where = (n) => `line ${lineOf(n.pos)} <${n.name}${n.attrs.id ? `#${n.attrs.id}` : ""}>`;
66 if (!links.length) errors.push(`${where(tags[0])}: tags need the kit; add <link rel="stylesheet" href="kit.css" data-ve-kit> to <head>`);
67 if (links.length > 1) errors.push(`${links.length} <link data-ve-kit> elements; keep one in <head>`);
68 const register = find(doc, (n) => n.name === "html")[0]?.attrs["data-register"] || "instrument";
69 if (!FONTS[register]) errors.push(`data-register="${register}" is not a register; use ${Object.keys(FONTS).join(", ")}`);
70 const body = find(doc, (n) => n.name === "body")[0];
71 if (!body) errors.push("the page has no <body>");
72 // a box or arrow keeps the data-ref and data-s it was written with, so it lights with its terms and its steps
73 const ref = (k) => {
74 if ("claim" in k.attrs) errors.push(`${where(k)}: claim= belongs to plan pages; link a part to its terms with data-ref`);
75 return ["data-ref", "data-s"].filter((a) => k.attrs[a] !== undefined).map((a) => ` ${a}="${esc(k.attrs[a])}"`).join("");
76 };
77 for (const n of find(doc, (x) => "data-if" in x.attrs)) errors.push(`${where(n)}: data-if belongs to plan pages`);
78 // the reader's reply is collected inside <main>, so an ask anywhere else would be answered and never reach the agent
79 if (asks.length) {
80 const main = find(doc, (n) => n.name === "main")[0];
81 const inMain = new Set(main ? find(main, (n) => n.name === "ve-ask") : []);
82 for (const a of asks) if (!inMain.has(a)) errors.push(`${where(a)}: <ve-ask> must be inside <main>, where the reader's reply is collected`);
83 }
84 const ids = new Map();
85 for (const t of tags) {
86 if (t.name === "ve-ask") t.html = expandAsk(t, { errors, warnings, where, ids, k: asks.indexOf(t) + 1, total: asks.length, page: true });
87 else t.html = t.name === "ve-flow" ? expandFlow(t, { errors, warnings, where, ref, page: true, root, models }) : TAGS[t.name](t, { errors, warnings, where });
88 }
89 // A walked flow replays its walk: the figure's captions must match its steps, or the render writes them (and the player).
90 const replayed = new Set();
91 for (const t of tags.filter((x) => x.machine?.steps)) {
92 const fig = find(doc, (f) => f.name === "figure" && hasClass(f, "ve-steps") && find(f, (x) => x === t).length).at(-1);
93 if (!fig) continue;
94 // one stepper steps through every caption in its figure, so two walks there would play as one
95 if (replayed.has(fig)) { errors.push(`${where(t)}: one walked <ve-flow> per <figure class="ve-steps">; put this one in its own figure`); continue; }
96 replayed.add(fig);
97 const { steps } = t.machine;
98 const player = find(fig, (x) => hasClass(x, "ve-player"))[0], ol = find(fig, (x) => hasClass(x, "ve-cap"))[0];
99 const caps = ol ? find(ol, (x) => x.name === "li") : [];
100 const lis = steps.map((s, i) => `<li data-n="${i + 1}/${steps.length}">${esc(s)}</li>`).join("");
101 if (caps.length && caps.length !== steps.length) errors.push(`${where(caps[0])}: the walk replays in ${plural(steps.length, "step")} (${steps.map((s) => `“${s}”`).join(", ")}); write ${steps.length} captions, or none and the render writes them`);
102 else if (caps.length) continue;
103 else if (ol) ol.html = `<ol class="ve-cap">${lis}</ol>`;
104 else if (player) player.children.unshift({ html: `<ol class="ve-cap">${lis}</ol>` });
105 else t.html += `<div class="ve-player"><ol class="ve-cap">${lis}</ol><button data-go="-1" aria-label="Previous step">‹</button><button data-play>Play</button><input type="range" aria-label="Step"><button data-go="1" aria-label="Next step">›</button></div>`;
106 }
107 // Mermaid becomes plain SVG here, so the page draws offline, follows the theme through CSS, and is checked like any drawing.
108 // A phone gets its own layout when Pele can make one narrower. Neither layout shrinks below its natural width (the frame
109 // scrolls instead), and Pele draws some labels smaller than its base size, so the base grows until the smallest is 12 px.
110 // a <ve-flow auto> becomes a diagram like one written in Mermaid, so it is drawn and checked the same way
111 for (const t of tags.filter((x) => x.auto)) mermaids.push({ name: "ve-flow", attrs: { "aria-label": t.attrs.label }, pos: t.pos, children: [{ text: t.auto.mermaid }], flow: t });
112 for (const [i, pre] of mermaids.entries()) {
113 const label = pre.attrs["aria-label"];
114 if (!label) { errors.push(`${where(pre)}: add aria-label="…" saying what the diagram shows`); continue; }
115 let wide, narrow;
116 try {
117 const text = decode(inner(pre));
118 const draw = (maxWidth, idPrefix) => {
119 for (let fontSize = 14; ; fontSize++) {
120 const r = drawMermaid(text, { fontSize, maxWidth, idPrefix });
121 if (fontSize >= 24 || Math.min(...[...r.svg.matchAll(/font-size(?:="|:\s*)([\d.]+)/g)].map((m) => +m[1])) >= 12) return r;
122 }
123 };
124 wide = draw(960, `ve-m${i}w`);
125 narrow = draw(280, `ve-m${i}n`);
126 } catch (e) {
127 if (!(e instanceof PeleError)) throw e;
128 errors.push(`${where(pre)}: Mermaid: ${e.message.replace(/\s*\n\s*/g, " ")}`);
129 continue;
130 }
131 const both = narrow.width < wide.width;
132 // Mermaid cannot put a class on a transition, so a walk's bug-path arrows are marked by the number Pele gives them
133 const lit = new Set(pre.flow?.auto.bugEdges.map((k) => `edge${k}`));
134 const svg = (r, cls) => r.svg.replace(' role="graphics-document document"', ` role="img" aria-label="${esc(label)}"`).replace('class="pele ', `class="pele${cls} `).replace('style="', `style="min-width:${r.width}px;`)
135 .replace(/class="pele-edge pele-transition" data-id="(edge\d+)"/g, (m, id) => (lit.has(id) ? m.replace("pele-transition", "pele-transition bug-path") : m));
136 pre.html = `<div class="ve-mermaid"${pre.attrs.id ? ` id="${esc(pre.attrs.id)}"` : ""}>${svg(wide, both ? " is-wide" : "")}${both ? svg(narrow, " is-narrow") : ""}</div>`;
137 if (pre.flow) pre.flow.html = `<div class="frame">${pre.html}</div>${pre.flow.auto.proof}`;
138 }
139 // Each drawing says what it shows, and a viewBox wider than 816 gets the min-width that keeps 14-unit labels at 12 px.
140 const drawings = [];
141 const top = (n) => { for (const c of kids(n)) if (c.name === "svg") drawings.push(c); else top(c); };
142 top(doc);
143 for (const svg of drawings) {
144 if (svg.attrs["aria-hidden"] === "true") continue;
145 if (!("aria-label" in svg.attrs) && !("aria-labelledby" in svg.attrs) && !kids(svg).some((k) => k.name === "title")) errors.push(`${where(svg)}: add aria-label="…" saying what the drawing shows (aria-hidden="true" if it is decoration)`);
146 if (!("role" in svg.attrs)) svg.openRaw = svg.openRaw.replace(/\s*(\/?)>$/, ' role="img"$1>');
147 const min = Math.ceil(((+(svg.attrs.viewbox ?? "").trim().split(/[\s,]+/)[2]) || 0) * 12 / 14);
148 if (!hasClass(svg, "ve-svg") || hasClass(svg, "ve-flow") || min <= 700 || /min-width|--ve-min/.test(svg.attrs.style ?? "")) continue;
149 const style = svg.openRaw.match(/\sstyle\s*=\s*(["'])(.*?)\1/is);
150 if (!style && svg.attrs.style !== undefined) continue;
151 svg.openRaw = style ? svg.openRaw.replace(style[0], ` style=${style[1]}${style[2].replace(/;?\s*$/, "")}; --ve-min: ${min}px${style[1]}`) : svg.openRaw.replace(/\s*(\/?)>$/, ` style="--ve-min: ${min}px"$1>`);
152 }
153 if (errors.length) return { html: source, kit: true, errors, warnings };
155 // a page with <ve-ask> also gets its styles and its part of the reply; a page with Mermaid gets the diagram colors
156 const css = ["kit.css", ...(asks.length ? ["ask.css"] : []), ...(mermaids.length ? ["mermaid.css"] : [])].map((f) => readFileSync(join(here, f), "utf8")).join("");
157 const js = ["reply.js", ...(asks.length ? ["ask.js"] : []), "kit.js"].map((f) => readFileSync(join(here, f), "utf8")).join("\n").replace(/<\/script/gi, "<\\/script");
158 const script = `<script data-ve-kit${sendBack ? " data-send-back" : ""}>\n${js}</script>`;
159 // without a charset, Firefox and Safari garble · and — (prepareRenderedHtml adds the viewport)
160 const charset = find(doc, (n) => n.name === "meta" && ("charset" in n.attrs || /^content-type$/i.test(n.attrs["http-equiv"] ?? ""))).length ? "" : '<meta charset="utf-8">\n';
161 links[0].html = `${charset}<link rel="preconnect" href="https://fonts.googleapis.com">
162<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
163<link href="https://fonts.googleapis.com/css2?${esc(FONTS[register])}&amp;display=swap" rel="stylesheet">
164<style data-ve-kit>
165${css}</style>`;
166 // The shared defs go first in <body>, each only if the page has no element with its id (the template has all four, so it opens raw).
167 const defs = readFileSync(join(here, "defs.svg"), "utf8").trimEnd().split("\n").filter((line) => {
168 const id = line.match(/^\s*<\w+ id="([^"]+)"/)?.[1];
169 return !id || !find(doc, (n) => n.attrs.id === id).length;
170 });
171 if (defs.length > 4) body.children.unshift({ html: `\n${defs.join("\n")}` });
172 const scripts = find(doc, (n) => n.name === "script" && "data-ve-kit" in n.attrs);
173 if (scripts.length) {
174 scripts[0].html = script;
175 for (const s of scripts.slice(1)) s.html = "";
176 } else body.children.push({ html: `${script}\n` });
177 return { html: serialize(doc), kit: true, errors, warnings, report: count(doc, charts) };
180const favicon = '<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 64 64\'%3E%3Crect width=\'64\' height=\'64\' rx=\'14\' fill=\'%230f172a\'/%3E%3Cpath d=\'M18 40V24l14-8 14 8v16l-14 8-14-8Z\' fill=\'none\' stroke=\'%23fbbf24\' stroke-width=\'4\' stroke-linejoin=\'round\'/%3E%3Ccircle cx=\'32\' cy=\'32\' r=\'5\' fill=\'%2338bdf8\'/%3E%3C/svg%3E">';
182/** The last step before a page is written (both hosts, every page; the CLI, kit pages): a favicon, `lang`, a `<head>`, and the viewport, each only when missing. */
183export function prepareRenderedHtml(html) {
184 let out = html;
185 if (!/<link\b[^>]*\brel=["'][^"']*(?:icon|shortcut icon)[^"']*["'][^>]*>/i.test(out)) {
186 if (/<\/title>/i.test(out)) out = out.replace(/<\/title>/i, `</title>\n${favicon}`);
187 else if (/<head[^>]*>/i.test(out)) out = out.replace(/<head[^>]*>/i, (head) => `${head}\n${favicon}`);
188 else out = out.replace(/<html[^>]*>/i, (tag) => `${tag}\n<head>\n${favicon}\n</head>`);
189 }
190 if (!/<html\b[^>]*\blang\s*=/i.test(out)) out = out.replace(/<html\b/i, '<html lang="en"');
191 if (!/<head[^>]*>/i.test(out)) out = out.replace(/<html[^>]*>/i, (tag) => `${tag}\n<head></head>`);
192 if (!/<meta\b[^>]*\bname\s*=\s*["']viewport["'][^>]*>/i.test(out)) out = out.replace(/<head[^>]*>/i, (head) => `${head}\n<meta name="viewport" content="width=device-width, initial-scale=1.0">`);
193 return out;
196/** `path` in both hosts: a <name>.src.html file inside the output directory (`dir()`), never a link out of it. */
197export function readSourcePath(input, dir) {
198 const raw = input.trim();
199 if (!/\.src\.html?$/i.test(raw)) throw new Error("path must name a <name>.src.html file");
200 const outputDir = realpathSync(dir());
201 const requested = resolve(outputDir, raw);
202 if (lstatSync(requested, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`${requested} must not be a symlink`);
203 const source = realpathSync(requested);
204 if (source !== outputDir && !source.startsWith(outputDir.endsWith(sep) ? outputDir : `${outputDir}${sep}`)) throw new Error(`${source} must stay inside ${outputDir}`);
205 return { html: readFileSync(source, "utf8"), filename: basename(source).replace(/\.src(\.html?)$/i, "$1"), source };
208/**
209 * The page step shared by the Pi tool and the MCP server. Returns null for an ordinary page. A plan source or a kit page
210 * becomes the page, renamed from <name>.src.html, with the source kept beside it.
211 */
212export function renderPageForHost(html, { root, filename, sendBack = false, models = true }) {
213 const plan = renderPlanForHost(html, { root, filename, sendBack, models });
214 if (plan) return plan;
215 const r = renderPage(html, { sendBack, root, models });
216 if (!r.kit) return null;
217 if (r.errors.length) throw new Error(`The page has ${r.errors.length} error(s); nothing was written:\n${r.errors.map((e) => `- ${e}`).join("\n")}`);
218 const page = filename.replace(/\.src(\.html?)$/i, "$1");
219 const source = page.replace(/\.html?$/i, ".src.html");
220 const warned = r.warnings.length ? `\nWarnings:\n${r.warnings.map((w) => `- ${w}`).join("\n")}` : "";
221 return { html: r.html, filename: page, source, kit: true, report: r.report, note: ` Source kept beside it as ${source}; edit it and render again for revisions.${warned}` };
224/* ── CLI ── */
225function openFile(path) {
226 const [cmd, args] = process.platform === "darwin" ? ["open", [path]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", path]] : ["xdg-open", [path]];
227 spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
230async function main() {
231 const argv = process.argv.slice(2);
232 const flag = (name) => argv.includes(name);
233 const value = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
234 const input = argv.find((a, i) => !a.startsWith("-") && !["-o", "--root"].includes(argv[i - 1]));
235 if (!input) {
236 console.error("usage: node render.mjs page.src.html [-o page.html] [--root <repo>] [--lint] [--open] [--no-check]");
237 process.exit(2);
238 }
239 const output = value("-o") ?? (/\.src\.html?$/i.test(input) ? input.replace(/\.src(\.html?)$/i, "$1") : null);
240 if (!output && !flag("--lint")) {
241 console.error("name the source <name>.src.html, or pass -o <name>.html");
242 process.exit(2);
243 }
244 const source = readFileSync(input, "utf8");
245 const root = value("--root") ?? process.cwd();
246 const result = hasPlan(source) ? renderPlan(source, { root }) : renderPage(source, { root });
247 const summary = result.summary ?? (result.kit ? "kit inlined" : "no kit marker; written as given");
248 for (const w of result.warnings) console.log(` ⚠ ${w}`);
249 for (const e of result.errors) console.log(` ✗ ${e}`);
250 if (result.errors.length) {
251 console.log(`✗ ${result.errors.length} error(s), ${result.warnings.length} warning(s); nothing written`);
252 process.exit(1);
253 }
254 const report = result.report ? `\n ${reportLine(result.report).replaceAll("\n", "\n ")}` : "";
255 if (flag("--lint")) { console.log(`✓ ${summary}${report}`); return; }
256 const temp = `${output}.${process.pid}.tmp`;
257 // a plan and an ordinary page are written as given; a kit page gets what the hosts add
258 writeFileSync(temp, result.kit ? prepareRenderedHtml(result.html) : result.html);
259 renameSync(temp, output);
260 console.log(`✓ ${resolve(output)} ${summary}${result.warnings.length ? ` · ${result.warnings.length} warning(s)` : ""}${report}`);
261 // a kit page is looked at in a browser, so the agent reads one report and one sheet instead of scripting its own screenshots
262 if (result.kit && !hasPlan(source) && !flag("--no-check")) {
263 const { lookAt, lookLines } = await import("./look.mjs");
264 const looked = await lookAt(output);
265 for (const line of lookLines(looked)) console.log(` ${line}`);
266 if (looked.problems.length) {
267 console.log(`✗ ${looked.problems.length} visual problem(s); the page was written. Fix them and render again.`);
268 process.exitCode = 1;
269 }
270 }
271 if (flag("--open")) openFile(resolve(output));
274// Node resolves symlinks for import.meta.url, so a renderer reached through a symlinked skill folder must compare real paths
275// A Bun-compiled host has no real file behind argv[1] ("/$bunfs/root/..."), so realpathSync throws on import; that is not the entry point.
276const isMain = (() => { try { return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; } catch { return false; } })();
277if (process.argv[1] && isMain) await main();