From 3b0ec4f53e5763ac40a24f6a6a6b8b660b9d4586 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 08:43:38 -0700 Subject: [PATCH] feat(f7): render annotated track-changes HTML (#21) Co-Authored-By: Claude Opus 4.8 --- src/trackChangesModel.ts | 68 ++++++++++++++++++++++++++++++++++ test/trackChangesModel.test.ts | 48 +++++++++++++++++++++++- 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index eb31cf2..17f6d54 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -121,3 +121,71 @@ export function diffBlocks(baselineText: string, currentText: string): BlockOp[] } 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); +}; + +/** 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(""); +} + +function safeRender(src: string): string { + try { + return md.render(src); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return `
Could not render this block: ${md.utils.escapeHtml(message)}
`; + } +} + +function renderOp(op: BlockOp): string { + let cls: string; + let inner: string; + let badge = ""; + switch (op.kind) { + case "unchanged": + cls = "cw-unchanged"; + inner = safeRender(op.block.raw); + break; + case "added": + cls = "cw-added"; + inner = safeRender(op.block.raw); + if (op.block.type !== "prose") badge = 'added'; + break; + case "removed": + cls = "cw-removed"; + inner = safeRender(op.block.raw); + break; + case "changed": + cls = "cw-changed"; + if (op.atomic) { + inner = safeRender(op.block.raw); // the NEW block, whole (INV-23) + badge = 'changed'; + } else { + inner = safeRender(wordMergedMarkdown(op.before.raw, op.block.raw)); + } + break; + } + return `
${badge}${inner}
`; +} + +/** Pure entry point: annotated HTML body for the preview (INV-22). */ +export function renderTrackChanges(baselineText: string, currentText: string): string { + return diffBlocks(baselineText, currentText).map(renderOp).join("\n"); +} diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index c969fea..b07bc04 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { splitBlocks, diffBlocks } from "../src/trackChangesModel"; +import { splitBlocks, diffBlocks, renderTrackChanges } from "../src/trackChangesModel"; describe("splitBlocks", () => { it("splits prose paragraphs on blank lines, dropping empties", () => { @@ -76,3 +76,49 @@ describe("diffBlocks", () => { expect(k).toContain("unchanged"); }); }); + +describe("renderTrackChanges", () => { + it("wraps each block in a cw-blk div with its kind class", () => { + const html = renderTrackChanges("Alpha.\n", "Alpha.\n\nBravo.\n"); + expect(html).toContain('class="cw-blk cw-unchanged"'); + expect(html).toContain('class="cw-blk cw-added"'); + }); + + it("emits inline / for a prose modification", () => { + const html = renderTrackChanges("The quick fox.\n", "The slow fox.\n"); + expect(html).toContain(""); + expect(html).toContain(""); + expect(html).toContain("cw-changed"); + }); + + it("renders a changed CODE fence atomically: cw-changed, no inline ins/del", () => { + const html = renderTrackChanges("```ts\nconst a = 1;\n```\n", "```ts\nconst a = 2;\n```\n"); + expect(html).toContain("cw-changed"); + expect(html).not.toContain(""); + expect(html).not.toContain(""); + expect(html).toContain("cw-badge"); + }); + + it('emits
 for a mermaid fence + a changed badge', () => {
+    const html = renderTrackChanges(
+      "```mermaid\nflowchart LR\n a-->b\n```\n",
+      "```mermaid\nflowchart LR\n a-->c\n```\n",
+    );
+    expect(html).toContain('
');
+    expect(html).toContain("a-->c"); // current source, escaped
+    expect(html).toContain("cw-badge");
+  });
+
+  it("unchanged doc renders with no marks", () => {
+    const html = renderTrackChanges("Alpha.\n", "Alpha.\n");
+    expect(html).not.toContain("cw-added");
+    expect(html).not.toContain("cw-removed");
+    expect(html).not.toContain("cw-changed");
+  });
+
+  it("is deterministic (same inputs → identical HTML) (INV-22)", () => {
+    const a = renderTrackChanges("X.\n\nY.\n", "X.\n\nZ.\n");
+    const b = renderTrackChanges("X.\n\nY.\n", "X.\n\nZ.\n");
+    expect(a).toBe(b);
+  });
+});