/** * trackChangesModel — F7 pure, vscode-free render engine (spec §6.2, INV-22/23). * * `renderTrackChanges(baselineText, currentText)` returns the annotated HTML body * for the preview webview: a block-level diff (LCS over normalized blocks) with * word-level ``/`` refinement inside changed PROSE blocks, and CODE / * MERMAID fences kept ATOMIC (INV-23 — never word-refined, never partially * rendered). Deterministic: same inputs → identical HTML (INV-22). No vscode, no * DOM — `markdown-it` and `diff` are libraries; mermaid runs later in the webview. */ import MarkdownIt from "markdown-it"; import { diffArrays, diffWords } from "diff"; import { diffMermaid } from "./mermaidDiff"; export type BlockType = "prose" | "code" | "mermaid"; export interface Block { /** Verbatim source of the block (no surrounding blank lines). */ raw: string; /** Normalized match key: lowercased, whitespace-collapsed. */ key: string; type: BlockType; } function normalize(raw: string): string { return raw.trim().replace(/\s+/g, " ").toLowerCase(); } function makeBlock(raw: string, type: BlockType): Block { return { raw, key: normalize(raw), type }; } /** Split markdown into top-level blocks; fenced code/mermaid stay whole. */ export function splitBlocks(text: string): Block[] { const lines = text.split(/\r?\n/); const blocks: Block[] = []; let buf: string[] = []; const flushProse = () => { const raw = buf.join("\n"); if (raw.trim()) blocks.push(makeBlock(raw, "prose")); buf = []; }; let i = 0; while (i < lines.length) { const line = lines[i]; const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/); if (fence) { flushProse(); const marker = fence[2][0]; // ` or ~ const info = fence[3].trim().split(/\s+/)[0].toLowerCase(); const fenceLines = [line]; i++; while (i < lines.length) { fenceLines.push(lines[i]); const closed = lines[i].trim().startsWith(marker.repeat(3)); i++; if (closed) break; } blocks.push(makeBlock(fenceLines.join("\n"), info === "mermaid" ? "mermaid" : "code")); continue; } if (line.trim() === "") { flushProse(); i++; continue; } buf.push(line); i++; } flushProse(); return blocks; } export interface BlockWithRange extends Block { /** char offset of the block's first char in the source string. */ start: number; /** char offset one past the block's last char (source.slice(start,end) === raw). */ end: number; } /** * Like splitBlocks, but each block carries its [start,end) char range in the * SOURCE string (offsets align with document.getText(), so F3 attribution * offsets map directly). Lines are tracked with their true source offsets — a * trailing CR stays in the line, so a CRLF source keeps correct offsets. */ export function splitBlocksWithRanges(text: string): BlockWithRange[] { const lines: { raw: string; start: number }[] = []; let from = 0; for (let i = 0; i <= text.length; i++) { if (i === text.length || text[i] === "\n") { lines.push({ raw: text.slice(from, i), start: from }); from = i + 1; if (i === text.length) break; } } const rangeOf = (lo: number, hi: number): { start: number; end: number } => ({ start: lines[lo].start, end: lines[hi].start + lines[hi].raw.length, }); const out: BlockWithRange[] = []; let buf: number[] = []; const flushProse = () => { if (buf.length) { const { start, end } = rangeOf(buf[0], buf[buf.length - 1]); const raw = text.slice(start, end); if (raw.trim()) out.push({ ...makeBlock(raw, "prose"), start, end }); } buf = []; }; let i = 0; while (i < lines.length) { const line = lines[i].raw; const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/); if (fence) { flushProse(); const marker = fence[2][0]; const info = fence[3].trim().split(/\s+/)[0].toLowerCase(); const open = i; i++; while (i < lines.length) { const closed = lines[i].raw.trim().startsWith(marker.repeat(3)); i++; if (closed) break; } const close = i - 1; const { start, end } = rangeOf(open, close); const raw = text.slice(start, end); out.push({ ...makeBlock(raw, info === "mermaid" ? "mermaid" : "code"), start, end }); continue; } if (line.trim() === "") { flushProse(); i++; continue; } buf.push(i); i++; } flushProse(); return out; } export type BlockOp = | { kind: "unchanged"; block: Block } | { kind: "added"; block: Block } | { kind: "removed"; block: Block } | { kind: "changed"; block: Block; before: Block; atomic: boolean }; /** * Diff two block sequences. Matching is by normalized key (jsdiff `diffArrays`); * adjacent removed-then-added runs are paired element-wise into `changed` ops, the * surplus staying `removed` / `added`. A `changed` op is ATOMIC (INV-23) when * either side is a code/mermaid fence — rendered whole, never word-refined. */ export function diffBlocks(baselineText: string, currentText: string): BlockOp[] { const before = splitBlocks(baselineText); const after = splitBlocks(currentText); const changes = diffArrays( before.map((b) => b.key), after.map((b) => b.key), ); const ops: BlockOp[] = []; let bi = 0; // index into `before` let ci = 0; // index into `after` for (let n = 0; n < changes.length; n++) { const ch = changes[n]; const count = ch.count ?? ch.value.length; if (!ch.added && !ch.removed) { for (let k = 0; k < count; k++) ops.push({ kind: "unchanged", block: after[ci++] }); bi += count; continue; } if (ch.removed) { const next = changes[n + 1]; const addCount = next?.added ? (next.count ?? next.value.length) : 0; const paired = Math.min(count, addCount); for (let k = 0; k < paired; k++) { const beforeBlk = before[bi++]; const afterBlk = after[ci++]; const atomic = beforeBlk.type !== "prose" || afterBlk.type !== "prose"; ops.push({ kind: "changed", block: afterBlk, before: beforeBlk, atomic }); } for (let k = paired; k < count; k++) ops.push({ kind: "removed", block: before[bi++] }); for (let k = paired; k < addCount; k++) ops.push({ kind: "added", block: after[ci++] }); if (next?.added) n++; // consumed the paired added run continue; } // a lone added run (no preceding removed) for (let k = 0; k < count; k++) ops.push({ kind: "added", block: after[ci++] }); } return ops; } const md = new MarkdownIt({ html: true, linkify: false, breaks: false }); // mermaid fences →
SRC
for client-side rendering; all // other fences fall through to markdown-it's default (escaped
).
const defaultFence = md.renderer.rules.fence!.bind(md.renderer.rules);
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
  const info = tokens[idx].info.trim().split(/\s+/)[0].toLowerCase();
  if (info === "mermaid") {
    return `
${md.utils.escapeHtml(tokens[idx].content.replace(/\n$/, ""))}
\n`; } return defaultFence(tokens, idx, options, env, self); }; /** Inner source of a ```mermaid fence (drops the fence lines), or null. */ function mermaidFenceBody(raw: string): string | null { const lines = raw.split(/\r?\n/); const open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/); if (!open || open[3]?.toLowerCase() !== "mermaid") return null; const body = lines.slice(1); if (body.length && /^(\s*)(`{3,}|~{3,})/.test(body[body.length - 1])) body.pop(); return body.join("\n"); } /** F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block. */ const MERMAID_LEGEND = '
' + 'added' + 'changed' + 'removed' + "
"; /** Build a markdown string with inline / from a word-level prose diff. */ function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string { return diffWords(beforeRaw, afterRaw) .map((part) => { if (part.added) return `${part.value}`; if (part.removed) return `${part.value}`; return part.value; }) .join(""); } export interface RenderOptions { /** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */ render?: (src: string) => string; } function defaultRender(src: string): string { return md.render(src); } function chip(message: string): string { return `
Could not render this block: ${md.utils.escapeHtml(message)}
`; } function renderOp(op: BlockOp, render: (src: string) => string): string { const safe = (src: string): string => { try { return render(src); } catch (err) { return chip(err instanceof Error ? err.message : String(err)); } }; let cls: string; let inner: string; let badge = ""; switch (op.kind) { case "unchanged": cls = "cw-unchanged"; inner = safe(op.block.raw); break; case "added": cls = "cw-added"; inner = safe(op.block.raw); if (op.block.type !== "prose") badge = 'added'; break; case "removed": cls = "cw-removed"; inner = safe(op.block.raw); break; case "changed": cls = "cw-changed"; if (op.atomic) { const curBody = op.block.type === "mermaid" ? mermaidFenceBody(op.block.raw) : null; const beforeBody = op.before.type === "mermaid" ? mermaidFenceBody(op.before.raw) : null; const md = curBody !== null && beforeBody !== null ? diffMermaid(beforeBody, curBody) : { kind: "fallback" as const }; if (md.kind === "augmented") { // Re-render the augmented diagram as a mermaid fence; add a legend, drop the badge (INV-29). inner = safe("```mermaid\n" + md.source + "\n```") + MERMAID_LEGEND; } else { inner = safe(op.block.raw); // the NEW block, whole (INV-23 / INV-30 fallback) badge = 'changed'; } } else { inner = safe(wordMergedMarkdown(op.before.raw, op.block.raw)); } break; } return `
${badge}${inner}
`; } export type AuthorKind = "claude" | "human"; export interface AuthorSpan { start: number; end: number; author: AuthorKind; } // Private-Use-Area sentinels (never appear in real content; markdown-it passes // them through as plain text). Paired open/close per author. const SENT = { claude: { open: "", close: "" }, human: { open: "", close: "" }, } as const; function isCloseSentinel(m: string): boolean { return m === SENT.claude.close || m === SENT.human.close; } function authorBadge(authors: Set): { cls: string; label: string } | null { if (authors.size === 0) return null; if (authors.size > 1) return { cls: "cw-mixed", label: "mixed" }; const only = [...authors][0]; return only === "claude" ? { cls: "cw-by-claude", label: "Claude" } : { cls: "cw-by-human", label: "You" }; } /** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string { const inserts: { at: number; marker: string }[] = []; for (const s of spans) { const lo = Math.max(0, s.start - blockStart); const hi = Math.min(raw.length, s.end - blockStart); if (hi <= lo) continue; inserts.push({ at: lo, marker: SENT[s.author].open }); inserts.push({ at: hi, marker: SENT[s.author].close }); } // Apply high offset → low so earlier offsets stay valid. At an equal offset // (one span's close == the next's open), apply opens BEFORE closes so the // close ends up left of the open: "……" for adjacent spans. inserts.sort((a, b) => b.at - a.at || (isCloseSentinel(a.marker) ? 1 : -1)); let out = raw; for (const ins of inserts) out = out.slice(0, ins.at) + ins.marker + out.slice(ins.at); return out; } /** Replace the rendered sentinels with author tags. */ function sentinelsToSpans(html: string): string { return html .split(SENT.claude.open).join('') .split(SENT.claude.close).join("") .split(SENT.human.open).join('') .split(SENT.human.close).join(""); } /** * Color one prose block's HTML by F3 author spans (PUA-sentinel technique, * salvaged from F9 renderAuthorship). `blockStart` is the block's char offset in * the source so spans map correctly. Pure; deterministic. */ export function colorByAuthor( raw: string, blockStart: number, spans: AuthorSpan[], render: (src: string) => string, ): string { const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length); const injected = injectSentinels(raw, blockStart, overlapping); return sentinelsToSpans(render(injected)); } /** Off-state body: the current buffer as plain markdown, no annotations (INV-33). */ export function renderPlain(currentText: string, opts: RenderOptions = {}): string { const render = opts.render ?? defaultRender; try { return render(currentText); } catch (err) { return chip(err instanceof Error ? err.message : String(err)); } } export interface ProposalView { id: string; /** resolved offsets in currentText; null when the anchor did not resolve. */ anchorStart: number | null; anchorEnd: number | null; /** the text the proposal would replace (fp.text), for the struck "before". */ replaced: string; /** the proposed replacement text. */ replacement: string; } function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string { const safe = (src: string): string => { try { return render(src); } catch (err) { return chip(err instanceof Error ? err.message : String(err)); } }; const unanchored = p.anchorStart === null ? " cw-proposal-unanchored" : ""; const before = p.replaced ? `${safe(p.replaced)}` : ""; const after = `${safe(p.replacement)}`; const actions = `` + `` + `` + ``; return `
${actions}${before}${after}
`; } function renderReviewOp( op: BlockOp, render: (src: string) => string, colored: (raw: string) => string, ): string { // removed blocks and any changed block (atomic fences diffed whole; non-atomic prose // word-merged) render via renderOp — no author sentinels (deletions neutral, spec §6.7). if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render); return `
${colored(op.block.raw)}
`; } /** * On-state body (INV-33): the F7 baseline diff — added/changed PROSE author-colored * via colorByAuthor (F9 sentinels), deletions struck — overlaid with F4 pending * proposals as blue cw-proposal blocks (✓/✗). One pass, pure, vscode-free. * Resolved proposals append after the diff body; unresolved ones append as trailing * cw-proposal-unanchored blocks (never dropped — INV-34). */ export function renderReview( baselineText: string, currentText: string, authorSpans: AuthorSpan[], proposals: ProposalView[], opts: RenderOptions = {}, ): string { const render = opts.render ?? defaultRender; const ranges = splitBlocksWithRanges(currentText); const ops = diffBlocks(baselineText, currentText); let ci = 0; // pointer into ranges; advances for every op with a current-side block const bodyParts = ops.map((op) => { const blk = op.kind === "removed" ? undefined : ranges[ci++]; const colored = (raw: string): string => blk ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw); return renderReviewOp(op, render, colored); }); const anchored = proposals.filter((p) => p.anchorStart !== null); const unanchored = proposals.filter((p) => p.anchorStart === null); const proposalParts = [...anchored, ...unanchored].map((p) => proposalBlockHtml(p, render)); return [...bodyParts, ...proposalParts].join("\n"); } /** Pure entry point: annotated HTML body for the preview (INV-22). */ export function renderTrackChanges( baselineText: string, currentText: string, opts: RenderOptions = {}, ): string { const render = opts.render ?? defaultRender; return diffBlocks(baselineText, currentText) .map((op) => renderOp(op, render)) .join("\n"); }