Skills › Visual Explainer › visual-explainer › render.mjs
render.mjs
558 lines · plugins/visual-explainer/plan/render.mjs at 9d5f880
1#!/usr/bin/env node
2// Turns a plan source page (<ve-*> tags) into one static, self-contained HTML page.
3// It checks the plan's shape, reads cited code from disk, and inlines plan.css, kit/reply.js, and plan.js.
4// No dependencies, so it runs from a copied skill folder.
5import { readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
6import { spawn } from "node:child_process";
7import { dirname, extname, join, relative, resolve } from "node:path";
8import { fileURLToPath, pathToFileURL } from "node:url";
9import { addClass, esc, find, hasClass, inner, kids, parseHtml, rawText, serialize, textOf, words } from "../kit/html.mjs";
10import { expandFlow, ifAttrs } from "../kit/flow.mjs";
11import { expandAsk as askTag } from "../kit/ask.mjs";
12import { checkAt, fileReader } from "../kit/files.mjs";
14const here = dirname(fileURLToPath(import.meta.url));
16/** True when the page holds a real <ve-plan> element, not one mentioned in a comment, script, or style. */
17export const hasPlan = (html) => find(parseHtml(html), (n) => n.name === "ve-plan").length > 0;
19/* ── code: read from disk inside the root, highlight, number ── */
20const SECRET_TEXT = /-----BEGIN [A-Z ]*PRIVATE KEY|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_\w{30,}|sk-(?:ant-)?[A-Za-z0-9_-]{32,}|xox[abeprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}/;
21const KEYWORDS = new Set(`abstract and as async await break case catch class const continue def default defer del delete do elif else enum except export extends false final finally fn for from func function go if impl import in instanceof interface is lambda let match mod mut new nil none not null of or package pass private protected pub public raise readonly return select self static struct super switch this throw throws trait true try type typeof undefined use var void where while with yield
22add alter by column constraint create cross distinct drop exists foreign group having index inner insert into join key left limit offset on order outer primary references returning right set skip table union unique update values when then end begin locked`.split(/\s+/));
23const HASH_COMMENTS = new Set(["py", "python", "sh", "bash", "zsh", "shell", "rb", "ruby", "yaml", "yml", "toml", "ini", "dockerfile", "make"]);
24const STRING = String.raw`"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'` + "|`(?:\\\\.|[^`\\\\])*`";
26/** Highlighted lines. Tokens never span a returned line, so each line is valid HTML on its own. */
27function highlight(code, lang) {
28 const l = (lang || "").toLowerCase();
29 if (!l || ["text", "plain", "txt", "md", "markdown"].includes(l)) return code.split("\n").map(esc);
30 const comment = HASH_COMMENTS.has(l) ? String.raw`#[^\n]*` : l === "sql" ? String.raw`--[^\n]*|\/\*[\s\S]*?\*\/` : String.raw`\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$)`;
31 const re = new RegExp(`(${comment})|(${STRING})|(\\b\\d[\\d_.]*\\b)|([A-Za-z_$][\\w$]*)`, "g");
32 const lines = [""];
33 const push = (t, cls) => t.split("\n").forEach((piece, i) => {
34 if (i) lines.push("");
35 if (piece) lines[lines.length - 1] += cls ? `<span class="tk-${cls}">${esc(piece)}</span>` : esc(piece);
36 });
37 let last = 0;
38 for (const m of code.matchAll(re)) {
39 push(code.slice(last, m.index));
40 push(m[0], m[1] ? "c" : m[2] ? "s" : m[3] ? "n" : KEYWORDS.has(l === "sql" ? m[4].toLowerCase() : m[4]) ? "k" : "");
41 last = m.index + m[0].length;
42 }
43 push(code.slice(last));
44 return lines;
45}
47function dedent(text) {
48 const lines = text.replace(/\t/g, " ").split("\n");
49 while (lines.length && !lines[0].trim()) lines.shift();
50 while (lines.length && !lines.at(-1).trim()) lines.pop();
51 const indent = Math.min(...lines.filter((l) => l.trim()).map((l) => l.match(/^ */)[0].length));
52 return lines.map((l) => l.slice(Number.isFinite(indent) ? indent : 0)).join("\n");
53}
55const EDITORS = { vscode: "vscode://file", cursor: "cursor://file", windsurf: "windsurf://file", zed: "zed://file" };
57/* ── the renderer ── */
58export function renderPlan(source, { root = process.cwd(), editor = process.env.VISUAL_EXPLAINER_EDITOR || "vscode", sendBack = false, models = true } = {}) {
59 const errors = [], warnings = [];
60 const lineOf = (pos) => source.slice(0, pos).split("\n").length;
61 const where = (n) => `line ${lineOf(n.pos)} <${n.name}${n.attrs.id ? `#${n.attrs.id}` : ""}>`;
62 const realRoot = realpathSync(root);
63 const filesRead = new Set();
64 const readFile = fileReader(realRoot);
65 const editorBase = EDITORS[editor] ?? null;
66 if (!editorBase && editor !== "none") warnings.push(`VISUAL_EXPLAINER_EDITOR="${editor}" is unknown (use ${Object.keys(EDITORS).join(", ")} or none); no editor links`);
68 const editorHref = (abs, line) => {
69 if (!editorBase || !abs) return null;
70 const p = abs.replace(/\\/g, "/");
71 return `${editorBase}${encodeURI(p.startsWith("/") ? p : `/${p}`)}${line ? `:${line}` : ""}`;
72 };
73 const fileLink = (label, abs, line) => {
74 const href = editorHref(abs, line);
75 return href ? `<a class="file-link" href="${esc(href)}" title="Open in editor">${esc(label)}</a>` : `<span class="file-link">${esc(label)}</span>`;
76 };
78 function codeRows(lines, { start = 1, hl = new Set(), pins = {}, target = 0, diff = false, lang }) {
79 const plain = diff ? lines.map((l) => (/^[+\- ]/.test(l) ? l.slice(1) : l)) : lines;
80 const html = highlight(plain.join("\n"), lang);
81 let num = start;
82 const rows = [];
83 lines.forEach((raw, i) => {
84 let cls = "", gutter, shown = null;
85 if (diff && raw.startsWith("@@")) {
86 const m = raw.match(/\+(\d+)/);
87 if (m) num = +m[1];
88 rows.push(`<tr class="hunk"><td class="ln">⋯</td><td class="src">${esc(raw)}</td></tr>`);
89 return;
90 }
91 if (diff && raw[0] === "-") { cls = "rem"; gutter = "−"; }
92 else { shown = num++; gutter = String(shown); if (diff && raw[0] === "+") cls = "add"; else if (hl.has(shown) || shown === target) cls = "hl"; }
93 rows.push(`<tr${cls ? ` class="${cls}"` : ""}><td class="ln">${gutter}</td><td class="src">${html[i] || " "}</td></tr>`);
94 for (const pin of (shown && pins[shown]) || []) rows.push(`<tr class="pin"><td class="ln"></td><td><span class="pin-text">↳ ${pin}</span></td></tr>`);
95 });
96 return rows.join("");
97 }
99 /* claims and decisions are numbered in document order before anything expands */
100 const doc = parseHtml(source);
101 const plans = find(doc, (n) => n.name === "ve-plan");
102 if (plans.length !== 1) return { errors: [`a plan needs exactly one <ve-plan>; found ${plans.length}`], warnings, files: [] };
103 const plan = plans[0];
104 const claims = [];
105 const numberClaims = (parent, prefix, level) => {
106 let i = 0;
107 for (const c of kids(parent).filter((k) => k.name === "ve-claim")) {
108 const aux = c.attrs.aux;
109 c.no = aux ? "" : `${prefix ? `${prefix}.` : ""}${++i}`;
110 c.level = level;
111 c.label = aux ? (aux === "scope" ? "not changing" : "shared") : `claim ${c.no}`;
112 if (aux && aux !== "shared" && aux !== "scope") errors.push(`${where(c)}: aux="${aux}"; use aux="shared" or aux="scope"`);
113 claims.push(c);
114 numberClaims(c, c.no || aux, level + 1);
115 }
116 };
117 numberClaims(plan, "", 1);
118 for (const c of find(doc, (n) => n.name === "ve-claim" && n.level === undefined)) errors.push(`${where(c)}: a <ve-claim> must sit directly inside <ve-plan> or another <ve-claim>`);
119 const claimNos = new Set(claims.map((c) => c.no).filter(Boolean));
120 const asks = find(doc, (n) => n.name === "ve-ask");
121 asks.forEach((a, i) => { a.k = i + 1; });
122 const askIds = new Map();
124 /* shape of the tree */
125 const tops = kids(plan).filter((k) => k.name === "ve-claim");
126 const topClaims = tops.filter((c) => !c.attrs.aux);
127 if (!topClaims.length) errors.push("the plan has no numbered claims");
128 if (topClaims.length > 5) warnings.push(`${topClaims.length} top-level claims; keep 2–5 and group the rest`);
129 for (const k of kids(plan)) if (k.name !== "ve-claim") warnings.push(`${where(k)}: only <ve-claim> belongs directly in <ve-plan>`);
130 const firstAux = tops.findIndex((c) => c.attrs.aux);
131 if (firstAux >= 0 && tops.slice(firstAux).some((c) => !c.attrs.aux)) warnings.push("put the aux claims (shared, not changing) after the numbered claims");
132 if (!tops.some((c) => c.attrs.aux === "scope")) warnings.push('no <ve-claim aux="scope">; end with what is not changing');
133 const EXHIBIT = new Set(["figure", "ve-code", "ve-calls", "ve-mock", "ve-flow", "table", "pre", "svg"]);
134 for (const c of claims) {
135 const els = kids(c);
136 const p = els[0]?.name === "p" ? els[0] : null;
137 c.p = p;
138 if (!p && !c.attrs.at) errors.push(`${where(c)} (${c.label}): the first child must be a <p> with the claim`);
139 const text = p ? textOf(p).replace(/\s+/g, " ").trim() : "";
140 c.text = text || c.attrs.at || "";
141 if (p && words(text) > 16) warnings.push(`${c.label}: ${words(text)} words; keep a claim to about 12`);
142 if (p && c.level <= 2 && !c.attrs.aux && !/[.?!]["”’)]?$/.test(text)) warnings.push(`${c.label}: "${text.slice(0, 48)}" reads as a heading; write a sentence that can be true or false`);
143 const exhibits = els.filter((e) => EXHIBIT.has(e.name));
144 const sub = els.filter((e) => e.name === "ve-claim");
145 if (exhibits.length > 1) warnings.push(`${c.label}: ${exhibits.length} exhibits (${exhibits.map((e) => e.name).join(", ")}); one per claim, so give the others child claims`);
146 if (!exhibits.length && !sub.length && c.attrs.aux !== "scope") warnings.push(`${c.label}: nothing proves it; add one exhibit`);
147 if (sub.length > 5) warnings.push(`${c.label}: ${sub.length} child claims; 5 at most`);
148 if (c.level > 3) warnings.push(`${c.label}: level ${c.level}; three levels at most (what › how › where)`);
149 const firstSub = els.findIndex((e) => e.name === "ve-claim");
150 if (firstSub >= 0 && els.slice(firstSub).some((e) => e.name === "ve-ask")) warnings.push(`${c.label}: put the decision after the exhibit and before the child claims`);
151 if (c.attrs.evidence && !["read", "inferred", "guess"].includes(c.attrs.evidence)) errors.push(`${c.label}: evidence="${c.attrs.evidence}"; use read, inferred, or guess`);
152 if (c.attrs.status && !["built", "changed", "dropped"].includes(c.attrs.status)) errors.push(`${c.label}: status="${c.attrs.status}"; use built, changed, or dropped`);
153 if ((c.attrs.status === "changed" || c.attrs.status === "dropped") && !c.attrs.note) warnings.push(`${c.label}: status="${c.attrs.status}" without note=""; say why`);
154 if (c.attrs.at) {
155 const r = checkAt(c.attrs.at, readFile);
156 if (r.error) errors.push(`${c.label}: ${r.error}`);
157 c.atAbs = r.abs;
158 }
159 }
160 if (asks.length > 6) warnings.push(`${asks.length} decisions; ask 2–5, only about forks that change what gets built, and default the rest`);
161 const h1 = find(doc, (n) => n.name === "h1")[0];
162 const h1Text = h1 ? textOf(h1).replace(/\s+/g, " ").trim() : "";
163 if (!h1) warnings.push("no <h1>; name the change and the place in 3–7 words");
164 else if (words(h1Text) > 8 || /[.!?]$/.test(h1Text)) warnings.push(`<h1> "${h1Text}" is a sentence; name the change and the place in 3–7 words`);
165 const titleNode = find(doc, (n) => n.name === "title")[0];
166 if (!titleNode || !textOf(titleNode).trim()) errors.push("missing <title>; it names the page and keys the reader's saved answers");
168 /* expanders: children expand before parents, so a claim sees its exhibits already rendered */
169 function expandCode(n) {
170 const at = where(n);
171 const src = n.attrs.src;
172 const script = kids(n).find((k) => k.name === "script");
173 const diff = "diff" in n.attrs;
174 let text, start = +(n.attrs.start || 1), abs = null;
175 const file = n.attrs.file || src || "";
176 if (src) {
177 const r = readFile(src);
178 if (r.error) { errors.push(`${at}: src="${src}": ${r.error}`); return ""; }
179 const all = r.text.replace(/\n$/, "").split("\n");
180 let a = 1, b = all.length;
181 if (n.attrs.lines) {
182 const m = n.attrs.lines.match(/^(\d+)(?:-(\d+))?$/);
183 if (!m) { errors.push(`${at}: lines="${n.attrs.lines}"; write lines="40-60"`); return ""; }
184 a = +m[1]; b = +(m[2] || m[1]);
185 if (a < 1 || a > b || b > all.length) { errors.push(`${at}: lines="${n.attrs.lines}", but ${src} has ${all.length} lines`); return ""; }
186 } else if (all.length > 40) warnings.push(`${at}: no lines= on a ${all.length}-line file; cite a range`);
187 text = all.slice(a - 1, b).join("\n");
188 start = a; abs = r.abs;
189 if (SECRET_TEXT.test(text)) { errors.push(`${at}: ${src} lines ${a}–${b} look like they hold a secret; not including them`); return ""; }
190 filesRead.add(src);
191 } else if (script) text = dedent(rawText(script));
192 else { errors.push(`${at}: give src="path" lines="a-b", or put the code in <script type="text/plain">`); return ""; }
193 const lines = text.split("\n");
194 if (lines.length > 40) warnings.push(`${at}: ${lines.length} lines; keep a slice to 10–25`);
195 const pins = {};
196 for (const p of kids(n).filter((k) => k.name === "ve-pin")) {
197 const line = +p.attrs.line;
198 if (!line) { errors.push(`${where(p)}: needs line="N" (the number shown in the gutter)`); continue; }
199 (pins[line] ||= []).push(inner(p).trim());
200 }
201 const shown = new Set();
202 let num = start;
203 for (const l of lines) { if (diff && l.startsWith("@@")) { const m = l.match(/\+(\d+)/); if (m) num = +m[1]; continue; } if (!(diff && l[0] === "-")) shown.add(num++); }
204 for (const line of Object.keys(pins)) if (!shown.has(+line)) errors.push(`${at}: <ve-pin line="${line}"> matches no line shown (${start}–${num - 1})`);
205 const hl = new Set();
206 for (const part of (n.attrs.hl || "").split(",")) { const m = part.trim().match(/^(\d+)(?:-(\d+))?$/); if (m) for (let i = +m[1]; i <= +(m[2] || m[1]); i++) hl.add(i); }
207 const lang = n.attrs.lang || extname(file).slice(1);
208 const title = n.attrs.title || file || "code";
209 const range = src || n.attrs.start ? `L${start}–${num - 1}` : "";
210 return `<figure class="code"><figcaption class="code-head">${abs ? fileLink(title, abs, start) : `<span class="file-link">${esc(title)}</span>`}${range ? `<span class="code-lines">${range}</span>` : ""}<span class="code-lang">${esc(diff ? `${lang || "text"} · diff` : lang)}</span></figcaption><div class="code-body"><table><tbody>${codeRows(lines, { start, hl, pins, diff, lang })}</tbody></table></div></figure>`;
211 }
213 function expandCalls(n) {
214 const at = where(n);
215 const script = kids(n).find((k) => k.name === "script");
216 if (!script && kids(n).length) { errors.push(`${at}: put the call lines in <script type="text/plain"> so < and > survive`); return ""; }
217 const rows = [];
218 for (const raw of dedent(script ? rawText(script) : textOf(n)).split("\n")) {
219 if (!raw.trim()) { rows.push({ sep: true }); continue; }
220 const mark = /^[+\-~?](?=\s)/.test(raw) ? raw[0] : " ";
221 const rest = mark === " " ? raw : ` ${raw.slice(1)}`;
222 let body = rest.trim(), note = "", loc = "";
223 const ni = body.search(/\s--\s/);
224 if (ni >= 0) { note = body.slice(ni).replace(/^\s--\s/, "").trim(); body = body.slice(0, ni).trim(); }
225 const li = body.search(/\s@\s*\S+$/);
226 if (li >= 0) { loc = body.slice(li).replace(/^\s@\s*/, ""); body = body.slice(0, li).trim(); }
227 rows.push({ mark, indent: rest.match(/^ */)[0].length, body, note, loc });
228 }
229 const real = rows.filter((r) => !r.sep);
230 if (!real.length) { errors.push(`${at}: no call lines`); return ""; }
231 const marked = real.filter((r) => r.mark !== " ");
232 const base = Math.min(...(marked.length ? marked : real).map((r) => r.indent));
233 for (const r of real) r.depth = Math.max(0, Math.round((r.indent - base) / 2));
234 // tree guides need to know whether a row is its parent's last child
235 real.forEach((r, i) => {
236 r.last = [];
237 for (let d = 1; d <= r.depth; d++) {
238 let isLast = true;
239 for (let j = rows.indexOf(r) + 1; j < rows.length && !rows[j].sep; j++) { if (rows[j].depth < d) break; if (rows[j].depth === d) { isLast = false; break; } }
240 r.last[d] = isLast;
241 }
242 });
243 const counts = { "+": 0, "-": 0, "~": 0 };
244 let noLoc = 0;
245 const html = rows.map((r) => {
246 if (r.sep) return '<div class="call-sep"></div>';
247 if (r.mark in counts) counts[r.mark]++;
248 if (r.mark !== " " && !r.loc) noLoc++;
249 let guide = "";
250 for (let d = 1; d <= r.depth; d++) guide += d === r.depth ? (r.last[d] ? "└─ " : "├─ ") : r.last[d] ? " " : "│ ";
251 const name = esc(r.body).replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
252 const cls = { "+": "add", "-": "rem", "~": "mod", "?": "opt", " ": "ctx" }[r.mark];
253 let excerpt = "", abs = null;
254 if (r.loc) {
255 const [path, lineStr] = r.loc.split(":");
256 if (lineStr !== undefined && !/^[1-9]\d*$/.test(lineStr)) errors.push(`${at}: "${r.body}" @ ${r.loc}; write @ path:line with a line number from 1`);
257 const line = +lineStr || 0;
258 const f = readFile(path);
259 if (f.missing && (r.mark === "+" || r.mark === "?")) { /* a new file */ }
260 else if (f.error) errors.push(`${at}: "${r.body}" @ ${r.loc}: ${f.error}`);
261 else {
262 const all = f.text.replace(/\n$/, "").split("\n");
263 if (line > all.length) (r.mark === "+" ? warnings : errors).push(`${at}: "${r.body}" @ ${r.loc}, but ${path} has ${all.length} lines`);
264 else if (line) {
265 const a = Math.max(1, line - 5), b = Math.min(all.length, line + 5);
266 const slice = all.slice(a - 1, b);
267 if (!SECRET_TEXT.test(slice.join("\n"))) {
268 abs = f.abs; filesRead.add(path);
269 excerpt = `<div class="excerpt"><p class="excerpt-head">${fileLink(`${path}:${line}`, abs, line)}<span class="code-lines">L${a}–${b}</span></p><div class="code-body"><table><tbody>${codeRows(slice, { start: a, target: line, lang: extname(path).slice(1) })}</tbody></table></div></div>`;
270 }
271 }
272 }
273 }
274 const row = `<span class="rail">${r.mark === "-" ? "−" : r.mark === " " ? "·" : r.mark}</span><span class="guide">${guide}</span><span class="nm">${name}</span>${r.note ? `<span class="note">${esc(r.note)}</span>` : ""}${r.loc ? `<span class="loc">${esc(r.loc)}</span>` : ""}`;
275 return excerpt ? `<details class="call-x"><summary class="call ${cls}">${row}</summary>${excerpt}</details>` : `<div class="call ${cls}">${row}</div>`;
276 }).join("");
277 if (!marked.length) warnings.push(`${at}: no + − ~ ? rows; mark what changes`);
278 if (noLoc > 2) warnings.push(`${at}: ${noLoc} marked rows have no "@ path:line"`);
279 if (real.length > 18) warnings.push(`${at}: ${real.length} rows; keep a call tree under about 15`);
280 return `<figure class="calls"><figcaption class="calls-head"><span class="calls-kind">calls</span>${n.attrs.title ? `<span class="calls-title">${esc(n.attrs.title)}</span>` : ""}<span class="calls-counts"><b class="add">+${counts["+"]}</b> <b class="rem">−${counts["-"]}</b> <b class="mod">~${counts["~"]}</b></span></figcaption><div class="calls-rows">${html}</div></figure>`;
281 }
283 function expandMock(n) {
284 const at = where(n);
285 const w = +(n.attrs.w || 440);
286 if (w > 640) warnings.push(`${at}: w="${w}"; draw the smallest region that makes the point (≤ 480)`);
287 const pins = kids(n).filter((k) => k.name === "ve-pin");
288 for (const p of pins) p.html = "";
289 const content = inner(n);
290 const marks = new Set([...content.matchAll(/data-pin\s*=\s*["']?([^"'\s>]+)/g)].map((m) => m[1]));
291 for (const p of pins) if (!marks.has(p.attrs.n)) errors.push(`${where(p)}: n="${p.attrs.n}" matches no data-pin inside the mock`);
292 const list = pins.length ? `<ol class="mock-pins">${pins.map((p) => `<li value="${esc(p.attrs.n)}">${inner(p).trim()}</li>`).join("")}</ol>` : "";
293 return `<figure class="mock">${n.attrs.label ? `<p class="mock-label">${esc(n.attrs.label)}</p>` : ""}<div class="mock-stage"><div class="mock-host" style="width:${w}px"><template shadowrootmode="open"><style>${MOCK_CSS}</style><div class="root">${content}</div></template></div></div>${list}</figure>`;
294 }
296 const expandAsk = (n) => askTag(n, { errors, warnings, where, ids: askIds, k: n.k, total: asks.length, claims: claimNos });
298 function expandFiles(n) {
299 const counts = { "+": 0, "~": 0, "-": 0 };
300 const items = [];
301 for (const raw of dedent(textOf(n)).split("\n")) {
302 if (!raw.trim()) continue;
303 const m = raw.trim().match(/^([+~-])\s+(\S+)(?:\s+#\s+(.*))?$/);
304 if (!m) { errors.push(`${where(n)}: "${raw.trim()}"; write "+ path", "~ path", or "- path", with an optional "# note"`); continue; }
305 counts[m[1]]++;
306 const f = readFile(m[2]);
307 // a new file need not exist yet, but every path must stay inside the repository
308 if (relative(realRoot, resolve(realRoot, m[2])).startsWith("..") || /outside/.test(f.error ?? "")) errors.push(`${where(n)}: ${m[1]} ${m[2]}: outside ${realRoot}`);
309 else if (m[1] !== "+" && f.missing) errors.push(`${where(n)}: ${m[1]} ${m[2]}: not found under ${realRoot}`);
310 items.push(`<li class="${{ "+": "add", "~": "mod", "-": "rem" }[m[1]]}"><span class="mk">${m[1] === "-" ? "−" : m[1]}</span><span class="path">${esc(m[2])}</span>${m[3] ? `<span class="note">${esc(m[3])}</span>` : ""}</li>`);
311 }
312 const total = counts["+"] + counts["~"] + counts["-"];
313 const part = (k, sign, word, cls) => `<span class="${cls}"${counts[k] ? "" : " hidden"}><b data-k="${word}">${sign}${counts[k]}</b> ${word}</span>`;
314 return `<div class="files" data-new="${counts["+"]}" data-changed="${counts["~"]}" data-deleted="${counts["-"]}"><p class="stat"><span class="stat-tag">Proposed</span><b data-k="total">${total} file${total === 1 ? "" : "s"}</b>${part("+", "+", "new", "add")}${part("~", "~", "changed", "mod")}${part("-", "−", "deleted", "rem")}</p><details class="file-map"><summary>File map</summary><ul>${items.join("")}</ul></details></div>`;
315 }
317 const expandQuote = (n) => `<blockquote class="quote"><p class="quote-head"><span class="via">${esc(n.attrs.via || "source")}</span>${n.attrs.from ? ` <b>${esc(n.attrs.from)}</b>` : ""}${n.attrs.at ? ` <span class="at">${n.attrs.href ? `<a href="${esc(n.attrs.href)}">${esc(n.attrs.at)}</a>` : esc(n.attrs.at)}</span>` : ""}</p><div class="quote-body">${inner(n).trim()}</div></blockquote>`;
318 const expandWhy = (n) => {
319 const count = kids(n).filter((k) => k.name === "ve-quote").length;
320 return `<details class="why"><summary>Why · ${count} request${count === 1 ? "" : "s"}</summary>${inner(n)}</details>`;
321 };
322 const expandRevision = (n) => `<section class="revision" aria-label="What changed"><p class="ve-label caps">Version ${esc(n.attrs.v || "2")} · changed since your answers</p><ul>${inner(n).trim()}</ul></section>`;
324 /* <ve-flow>, laid out by kit/flow.mjs; a box or arrow links to its claims, and each claim gets one numbered callout, on the first box that names it */
325 const refOf = (k) => {
326 const nos = (k.attrs.claim || "").split(/\s+/).filter(Boolean);
327 for (const no of nos) if (!claimNos.has(no)) errors.push(`${where(k)}: claim="${no}", but there is no claim ${no}`);
328 return nos.length ? ` data-ref="${nos.map((no) => `claim-${esc(no)}`).join(" ")}"` : "";
329 };
330 const flow = (n) => {
331 const called = new Set();
332 // the callout shows and hides with its box
333 const callout = (k, x, y) => (k.attrs.claim || "").split(/\s+/).filter((no) => claimNos.has(no) && !called.has(no))
334 .map((no, i) => { called.add(no); return `<g${ifAttrs(k, "ve-co")} data-ref="claim-${esc(no)}" transform="translate(${x + i * 26} ${y})"><circle r="11"/><text>${esc(no)}</text></g>`; }).join("");
335 return expandFlow(n, { errors, warnings, where, ref: refOf, callout, root, models });
336 };
338 const CHIP = { built: ["ok", "Built"], changed: ["warn", "Changed"], dropped: ["risk", "Dropped"] };
339 function expandClaim(c) {
340 if (c.level === undefined) return ""; // misplaced; reported above
341 const own = [], sub = [];
342 for (const k of c.children) (k.name === "ve-claim" ? sub : own).push(k);
343 if (c.p) c.p.html = "";
344 const askCount = find(c, (n) => n.name === "ve-ask").length;
345 const { evidence, status, rev, check, aux } = c.attrs;
346 const chip = CHIP[status]; // undefined for an invalid status, which was already reported
347 const chips = [
348 evidence === "guess" ? '<span class="chip warn">guess</span>' : evidence === "inferred" ? '<span class="chip info">inferred</span>' : "",
349 rev ? `<span class="chip info">changed in v${esc(rev)}</span>` : "",
350 chip ? `<span class="chip ${chip[0]}">${chip[1]}</span>` : "",
351 askCount ? `<span class="c-asks">${askCount} decision${askCount === 1 ? "" : "s"}</span>` : "",
352 ].join("");
353 const claimHtml = c.p ? inner(c.p).trim() : `<code>${esc(c.attrs.at)}</code>`;
354 const atLine = c.attrs.at && c.p ? `<p class="c-at">${fileLink(c.attrs.at, c.atAbs, c.attrs.at.split(":")[1])}</p>` : c.attrs.at && c.atAbs ? `<p class="c-at">${fileLink("open in editor", c.atAbs, c.attrs.at.split(":")[1])}</p>` : "";
355 const guess = evidence === "guess" ? `<fieldset class="guess" data-guess="${esc(c.no)}"><legend>I could not confirm this in the code. Is it right?</legend><label><input type="radio" name="guess:${esc(c.no)}" value="right"> Right</label><label><input type="radio" name="guess:${esc(c.no)}" value="wrong"> Wrong</label></fieldset>` : "";
356 const checkLine = check ? `<p class="c-check"><span class="ve-label">Done when</span> ${esc(check).replace(/`([^`]+)`/g, "<code>$1</code>")}</p>` : "";
357 const statusLine = chip && (c.attrs.note || c.attrs.ref) ? `<p class="c-status"><span class="chip ${chip[0]}">${chip[1]}</span> ${esc(c.attrs.note || "")}${c.attrs.ref ? ` <code>${esc(c.attrs.ref)}</code>` : ""}</p>` : "";
358 const id = `claim-${(c.no || aux).replace(/\./g, "-")}`;
359 const data = [["no", c.no], ["l", c.level], ["aux", aux], ["evidence", evidence], ["status", status], ["rev", rev]].filter(([, v]) => v).map(([k, v]) => ` data-${k}="${esc(v)}"`).join("");
360 const num = aux === "scope" ? "—" : aux ? "∗" : c.no;
361 return `<details class="claim" id="${esc(id)}"${data}><summary${c.no ? ` data-ref="claim-${esc(c.no)}"` : ""}><span class="c-no">${esc(num)}</span><span class="c-text">${claimHtml}</span><span class="c-meta">${chips}</span></summary><div class="c-body">${atLine}${own.map(serialize).join("")}${guess}${checkLine}${statusLine}${sub.map(serialize).join("")}</div></details>`;
362 }
364 const EXPAND = { "ve-code": expandCode, "ve-calls": expandCalls, "ve-mock": expandMock, "ve-flow": flow, "ve-ask": expandAsk, "ve-files": expandFiles, "ve-quote": expandQuote, "ve-why": expandWhy, "ve-revision": expandRevision, "ve-claim": expandClaim };
365 const PARTS = ["ve-plan", "ve-pin", "ve-opt", "ve-node", "ve-edge"];
367 /* answers shown or hidden by data-if start in the state the defaults give. This runs before expansion,
368 because expansion freezes each subtree into a string. */
369 const defaultsOf = Object.fromEntries(asks.map((a) => [a.attrs.id, kids(a).filter((o) => o.name === "ve-opt" && "default" in o.attrs).map((o) => o.attrs.value)]));
370 for (const n of find(doc, (x) => "data-if" in x.attrs)) {
371 const conds = n.attrs["data-if"].split(/\s*&&\s*/);
372 let on = true;
373 for (const cond of conds) {
374 const m = cond.match(/^([\w-]+)\s*(!=|=)\s*(.+)$/);
375 if (!m) { errors.push(`${where(n)}: data-if="${cond}"; write data-if="decision=value" (or !=, joined with &&)`); continue; }
376 const ask = asks.find((a) => a.attrs.id === m[1]);
377 if (!ask) { errors.push(`${where(n)}: data-if names "${m[1]}", but no <ve-ask id="${m[1]}"> exists`); continue; }
378 if (!kids(ask).some((o) => o.name === "ve-opt" && o.attrs.value === m[3].trim())) errors.push(`${where(n)}: data-if value "${m[3].trim()}" is not an option of "${m[1]}"`);
379 const hit = defaultsOf[m[1]].includes(m[3].trim());
380 if (m[2] === "=" ? !hit : hit) on = false;
381 }
382 if (["ve-plan", "ve-pin", "ve-opt"].includes(n.name)) errors.push(`${where(n)}: data-if does not work on <${n.name}>; put it on an element inside or around it`);
383 else if (n.name.startsWith("ve-")) n.ifOff = !on;
384 else if (!on) addClass(n, "is-if-off");
385 }
386 // a tag's data-if moves onto the element it expands to, so the reader's answers can still show or hide it
387 const carryIf = (n, html) => (!n.attrs["data-if"] || !html ? html : html.replace(/^<([a-z][\w-]*)([^>]*)>/i, (_, tag, rest) => {
388 const cls = rest.match(/\sclass="([^"]*)"/);
389 return `<${tag}${ifAttrs(n, cls?.[1] ?? "")}${cls ? rest.replace(cls[0], "") : rest}>`;
390 }));
391 const ves = find(doc, (n) => n.name.startsWith("ve-"));
392 for (const n of ves) if (!(n.name in EXPAND) && !PARTS.includes(n.name)) errors.push(`${where(n)}: unknown tag; known: ${Object.keys(EXPAND).concat(PARTS).join(" ")}`);
393 for (const n of ves.slice().reverse()) if (EXPAND[n.name]) n.html = carryIf(n, EXPAND[n.name](n));
395 /* hero: the answer as a picture, its callouts linked to the claims */
396 const hero = find(doc, (n) => n.name === "figure" && hasClass(n, "hero"))[0];
397 if (!hero) warnings.push('no <figure class="hero">; open on the change as a picture with ① ② ③ linked to the claims');
398 else {
399 const refs = new Set(find(hero, (n) => n.attrs["data-ref"] || n.attrs.claim).flatMap((n) => (n.attrs["data-ref"] ?? "").split(/\s+/).concat((n.attrs.claim ?? "").split(/\s+/).filter(Boolean).map((no) => `claim-${no}`))).filter((r) => r.startsWith("claim-")));
400 for (const r of refs) if (!claimNos.has(r.slice(6))) errors.push(`hero: data-ref="${r}", but there is no claim ${r.slice(6)}`);
401 for (const c of topClaims) if (!refs.has(`claim-${c.no}`)) warnings.push(`hero: nothing carries data-ref="claim-${c.no}"; mark the parts claim ${c.no} changes`);
402 const vb = find(hero, (n) => n.name === "svg")[0]?.attrs.viewbox?.split(/[\s,]+/).map(Number);
403 if (vb?.length === 4 && vb[3] / vb[2] > 0.72) warnings.push(`hero: viewBox ${vb[2]}×${vb[3]} is tall; keep it near 560×340 so the tree stays in view`);
404 }
406 /* layout: header, then the tree beside the sticky hero */
407 const htmlNode = find(doc, (n) => n.name === "html")[0];
408 const head = find(doc, (n) => n.name === "head")[0];
409 const body = find(doc, (n) => n.name === "body")[0];
410 if (!body) errors.push("missing <body>");
411 if (errors.length) return { errors, warnings, files: [...filesRead] };
413 const header = find(body, (n) => n.name === "header")[0];
414 const take = (n) => { if (!n) return ""; const s = serialize(n); n.html = ""; return s; };
415 // the claims go into the page without the <ve-plan> wrapper, so a rendered page never reads as a plan source
416 const headerHtml = take(header), heroHtml = take(hero), planHtml = inner(plan);
417 plan.html = "";
418 const mainNode = kids(body).length === 1 && kids(body)[0].name === "main" ? kids(body)[0] : body;
419 const rest = inner(mainNode).trim();
421 const guesses = claims.filter((c) => c.attrs.evidence === "guess").length;
422 const checked = claims.filter((c) => c.attrs.check);
423 const statuses = claims.filter((c) => c.attrs.status);
424 // the saved-answers key includes this, so each revision starts clean
425 const version = Math.max(1, ...find(doc, (n) => n.name === "ve-revision").map((n) => +n.attrs.v || 2), ...claims.map((c) => +c.attrs.rev || 1));
426 const proseWords = find(plan, (n) => n.name === "p" || n.name === "li").reduce((s, n) => s + words(textOf(n)), 0);
427 const minutes = Math.max(1, Math.round(proseWords / 220 + asks.length * 0.3 + claims.length * 0.15));
428 const meta = [
429 `${claims.filter((c) => c.no).length} claims`,
430 asks.length ? `${asks.length} decision${asks.length === 1 ? "" : "s"}` : "",
431 guesses && !statuses.length ? `<span class="guesses">${guesses} guess${guesses === 1 ? "" : "es"} to check</span>` : "",
432 `~${minutes} min`,
433 ].filter(Boolean).join(" · ");
434 let progress = "";
435 if (statuses.length) {
436 const tally = { built: 0, changed: 0, dropped: 0 };
437 for (const c of statuses) tally[c.attrs.status]++;
438 const total = claims.filter((c) => c.attrs.check || c.attrs.status).length;
439 const seg = (k) => (tally[k] ? `<i class="${k}" style="flex:${tally[k]}"></i>` : "");
440 progress = `<p class="progress"><span class="bar" role="img" aria-label="${tally.built} of ${total} built, ${tally.changed} changed, ${tally.dropped} dropped">${seg("built")}${seg("changed")}${seg("dropped")}${total > statuses.length ? `<i class="todo" style="flex:${total - statuses.length}"></i>` : ""}</span>${tally.built} of ${total} built${tally.changed ? ` · ${tally.changed} changed` : ""}${tally.dropped ? ` · ${tally.dropped} dropped` : ""}</p>`;
441 }
442 const done = checked.length ? `<section class="done" aria-label="Done when"><p class="ve-label caps">Done when</p><table><thead><tr><th>Claim</th><th>Check</th><th>Status</th></tr></thead><tbody>${checked.map((c) => `<tr><td><a href="#claim-${esc((c.no || c.attrs.aux).replace(/\./g, "-"))}">${esc(c.no || c.label)}</a> ${esc(c.text)}</td><td>${esc(c.attrs.check).replace(/`([^`]+)`/g, "<code>$1</code>")}</td><td>${c.attrs.status ? `<span class="chip ${CHIP[c.attrs.status][0]}">${CHIP[c.attrs.status][1]}</span>` : '<span class="ve-label">planned</span>'}</td></tr>`).join("")}</tbody></table></section>` : "";
444 const keepHead = head ? kids(head).filter((k) => !(k.name === "meta" && ("charset" in k.attrs || k.attrs.name === "viewport")) && k.name !== "title").map(serialize).join("\n") : "";
445 const hasIcon = head && kids(head).some((k) => k.name === "link" && /icon/.test(k.attrs.rel || ""));
446 const css = readFileSync(join(here, "plan.css"), "utf8");
447 const js = [join(here, "../kit/reply.js"), join(here, "plan.js")].map((f) => readFileSync(f, "utf8")).join("\n").replace(/<\/script/gi, "<\\/script");
448 const lang = htmlNode?.attrs.lang || "en";
449 const bodyAttrs = body.openRaw.replace(/^<body/i, "").replace(/>$/, "");
450 const html = `<!doctype html>
451<html lang="${esc(lang)}">
452<head>
453<meta charset="utf-8">
454<meta name="viewport" content="width=device-width, initial-scale=1">
455<title>${esc(textOf(titleNode).trim())}</title>
456${hasIcon ? "" : FAVICON}
457<link rel="preconnect" href="https://fonts.googleapis.com">
458<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
459<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
460<style data-ve-plan>
461${css}
462</style>
463${keepHead}
464</head>
465<body${bodyAttrs}>
466${DEFS}
467<main class="plan-page${hero ? "" : " no-hero"}" data-v="${version}"${sendBack ? " data-send-back" : ""}>
468${headerHtml}
469<div class="plan-grid">
470<div class="plan-col">
471<div class="plan-meta"><p class="counts">${meta}</p>${progress}<p class="tools"></p></div>
472<section class="plan" aria-label="Plan">${planHtml}</section>
473${done}
474</div>
475${hero ? `<aside class="plan-hero">${heroHtml}</aside>` : ""}
476</div>
477${rest}
478</main>
479<script data-ve-plan>
480${js}
481</script>
482</body>
483</html>
484`;
485 const count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
486 const summary = [count(claims.filter((c) => c.no).length, "claim"), count(asks.length, "decision"), count(guesses, "guess", "guesses"), count(checked.length, "check"), statuses.length ? `${statuses.length} with status` : "", `${count(filesRead.size, "file")} read`].filter(Boolean).join(" · ");
487 return { html, errors, warnings, files: [...filesRead], summary };
488}
490/**
491 * The plan step shared by the Pi tool and the MCP server. Returns null for an ordinary page. A plan source becomes the
492 * page, renamed from <name>.src.html, with the source kept beside it. A page already rendered passes through untouched.
493 */
494export function renderPlanForHost(html, { root, filename, sendBack = false, models = true }) {
495 if (!hasPlan(html)) return /<script data-ve-plan>/.test(html) ? { html, filename, note: "" } : null;
496 const r = renderPlan(html, { root, sendBack, models });
497 if (r.errors.length) throw new Error(`The plan has ${r.errors.length} error(s); nothing was written:\n${r.errors.map((e) => `- ${e}`).join("\n")}`);
498 const page = filename.replace(/\.src(\.html?)$/i, "$1");
499 const source = page.replace(/\.html?$/i, ".src.html");
500 const warned = r.warnings.length ? `\nWarnings:\n${r.warnings.map((w) => `- ${w}`).join("\n")}` : "";
501 return { html: r.html, filename: page, source, note: ` Plan: ${r.summary}. Source kept beside it as ${source}; edit it and render again for revisions and the build receipt.${warned}` };
502}
504const 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='8' fill='%230d1520'/%3E%3Cpath d='M18 20h28M18 32h20M18 44h12' stroke='%23ffb547' stroke-width='5' stroke-linecap='round'/%3E%3C/svg%3E">`;
506// Shared SVG defs for hand-drawn figures: one arrowhead that follows each edge's color, and the node fill.
507const DEFS = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><marker id="ve-ah" viewBox="0 0 10 10" refX="9" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M0 0L10 5L0 10z" fill="context-stroke"/></marker><linearGradient id="ve-node" x1="0" y1="0" x2="0" y2="1"><stop offset="0" style="stop-color:var(--node-top)"/><stop offset="1" style="stop-color:var(--surface)"/></linearGradient></defs></svg>`;
509// Mock UI lives in a shadow root, so page styles never reach it. These classes save writing CSS for common parts.
510const MOCK_CSS = `:host{all:initial;display:block}*,*::before,*::after{box-sizing:border-box}
511.root{font:14px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif;color:#17191c}
512.ui{background:#fff;border:1px solid #d9dde3;border-radius:10px;overflow:hidden}
513.hd{display:flex;gap:8px;align-items:baseline;padding:10px 14px;border-bottom:1px solid #eceef1;font-weight:600}.hd small{color:#6b7280;font-weight:400}
514.row{display:flex;gap:12px;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid #f0f1f3}.row:last-child{border-bottom:0}
515.muted{color:#6b7280}.small{font-size:12.5px}.ok{color:#1c7443;font-weight:600}.bad{color:#b0341e;font-weight:600}
516.btn{display:inline-block;font-weight:600;font-size:13px;padding:6px 11px;border-radius:7px;border:1px solid #d9dde3;background:#fff;color:#17191c}.btn.pri{background:#17191c;color:#fff;border-color:#17191c}.btn.danger{color:#b0341e}
517.ft{display:flex;gap:8px;justify-content:flex-end;padding:10px 14px;border-top:1px solid #eceef1;background:#f8f9fa}
518.is-if-off{display:none!important}
519[data-pin]{position:relative}[data-pin]::after{content:attr(data-pin);position:absolute;top:-11px;right:-11px;min-width:20px;height:20px;padding:0 5px;border-radius:10px;background:var(--accent,#c06a00);color:var(--bg,#fff);font:600 12px/20px ui-monospace,monospace;text-align:center;box-shadow:0 0 0 2px var(--surface,#fff)}`;
521/* ── CLI ── */
522function openFile(path) {
523 const [cmd, args] = process.platform === "darwin" ? ["open", [path]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", path]] : ["xdg-open", [path]];
524 spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
525}
527function main() {
528 const argv = process.argv.slice(2);
529 const flag = (name) => argv.includes(name);
530 const value = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
531 const input = argv.find((a, i) => !a.startsWith("-") && !["-o", "--root"].includes(argv[i - 1]));
532 if (!input) {
533 console.error("usage: node render.mjs plan.src.html [-o plan.html] [--root <repo>] [--lint] [--open]");
534 process.exit(2);
535 }
536 const output = value("-o") ?? (/\.src\.html?$/i.test(input) ? input.replace(/\.src(\.html?)$/i, "$1") : null);
537 if (!output && !flag("--lint")) {
538 console.error("name the source <name>.src.html, or pass -o <name>.html");
539 process.exit(2);
540 }
541 const result = renderPlan(readFileSync(input, "utf8"), { root: value("--root") ?? process.cwd() });
542 for (const w of result.warnings) console.log(` ⚠ ${w}`);
543 for (const e of result.errors) console.log(` ✗ ${e}`);
544 if (result.errors.length) {
545 console.log(`✗ ${result.errors.length} error(s), ${result.warnings.length} warning(s); nothing written`);
546 process.exit(1);
547 }
548 if (result.files.length) console.log(` code from ${result.files.length} file(s) is now in the page: ${result.files.join(", ")}`);
549 if (flag("--lint")) { console.log(`✓ ${result.summary}`); return; }
550 const temp = `${output}.${process.pid}.tmp`;
551 writeFileSync(temp, result.html);
552 renameSync(temp, output);
553 console.log(`✓ ${resolve(output)} ${result.summary}${result.warnings.length ? ` · ${result.warnings.length} warning(s)` : ""}`);
554 if (flag("--open")) openFile(resolve(output));
555}
557const isMain = (() => { try { return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; } catch { return false; } })();
558if (process.argv[1] && isMain) main();