/** * 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"; 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 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; }