feat(f7.1): render augmented mermaid diff in changed-block branch (#22)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-11 16:33:12 -07:00
parent e938572ebc
commit 6702490497
2 changed files with 67 additions and 5 deletions
+32 -2
View File
@@ -10,6 +10,7 @@
*/ */
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import { diffArrays, diffWords } from "diff"; import { diffArrays, diffWords } from "diff";
import { diffMermaid } from "./mermaidDiff";
export type BlockType = "prose" | "code" | "mermaid"; export type BlockType = "prose" | "code" | "mermaid";
@@ -204,6 +205,24 @@ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
return defaultFence(tokens, idx, options, env, self); 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 =
'<div class="cw-mermaid-legend">' +
'<span class="cw-leg cw-leg-add">added</span>' +
'<span class="cw-leg cw-leg-chg">changed</span>' +
'<span class="cw-leg cw-leg-rem">removed</span>' +
"</div>";
/** Build a markdown string with inline <ins>/<del> from a word-level prose diff. */ /** Build a markdown string with inline <ins>/<del> from a word-level prose diff. */
function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string { function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
return diffWords(beforeRaw, afterRaw) return diffWords(beforeRaw, afterRaw)
@@ -256,8 +275,19 @@ function renderOp(op: BlockOp, render: (src: string) => string): string {
case "changed": case "changed":
cls = "cw-changed"; cls = "cw-changed";
if (op.atomic) { if (op.atomic) {
inner = safe(op.block.raw); // the NEW block, whole (INV-23) const curBody = op.block.type === "mermaid" ? mermaidFenceBody(op.block.raw) : null;
badge = '<span class="cw-badge">changed</span>'; 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 = '<span class="cw-badge">changed</span>';
}
} else { } else {
inner = safe(wordMergedMarkdown(op.before.raw, op.block.raw)); inner = safe(wordMergedMarkdown(op.before.raw, op.block.raw));
} }
+35 -3
View File
@@ -99,14 +99,16 @@ describe("renderTrackChanges", () => {
expect(html).toContain("cw-badge"); expect(html).toContain("cw-badge");
}); });
it('emits <pre class="mermaid"> for a mermaid fence + a changed badge', () => { it('emits <pre class="mermaid"> for a changed flowchart, augmented intra-diagram (#22)', () => {
const html = renderTrackChanges( const html = renderTrackChanges(
"```mermaid\nflowchart LR\n a-->b\n```\n", "```mermaid\nflowchart LR\n a-->b\n```\n",
"```mermaid\nflowchart LR\n a-->c\n```\n", "```mermaid\nflowchart LR\n a-->c\n```\n",
); );
expect(html).toContain('<pre class="mermaid">'); expect(html).toContain('<pre class="mermaid">');
expect(html).toContain("a--&gt;c"); // current source, escaped expect(html).toContain("a--&gt;c"); // current source preserved as prefix, escaped
expect(html).toContain("cw-badge"); // a changed flowchart is now diffed in place (INV-29): styling, not a whole-block badge
expect(html).toContain("classDef cwAdded");
expect(html).not.toContain('<span class="cw-badge">changed</span>');
}); });
it("unchanged doc renders with no marks", () => { it("unchanged doc renders with no marks", () => {
@@ -232,3 +234,33 @@ describe("renderAuthorship", () => {
expect(renderAuthorship(text, spans)).toBe(renderAuthorship(text, spans)); expect(renderAuthorship(text, spans)).toBe(renderAuthorship(text, spans));
}); });
}); });
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
describe("renderTrackChanges — intra-diagram mermaid (#22)", () => {
it("augments a changed flowchart with styling directives (INV-29)", () => {
const base = "```mermaid\nflowchart LR\n A-->B\n```\n";
const cur = "```mermaid\nflowchart LR\n A-->B\n B-->C\n```\n";
const html = rtc2(base, cur);
expect(html).toContain("classDef cwAdded");
expect(html).toMatch(/class\s+C\s+cwAdded/);
expect(html).toContain('class="cw-mermaid-legend"');
// no single whole-block "changed" badge when we augmented
expect(html).not.toContain('<span class="cw-badge">changed</span>');
});
it("augments a changed sequence diagram", () => {
const base = "```mermaid\nsequenceDiagram\n A->>B: hi\n```\n";
const cur = "```mermaid\nsequenceDiagram\n A->>B: hi\n B->>C: on\n```\n";
const html = rtc2(base, cur);
expect(html).toContain("rect rgb(46, 160, 67)");
});
it("falls back to the v1 badge for an unsupported diagram type (INV-30)", () => {
const base = "```mermaid\nclassDiagram\n class A\n```\n";
const cur = "```mermaid\nclassDiagram\n class B\n```\n";
const html = rtc2(base, cur);
expect(html).toContain('<span class="cw-badge">changed</span>');
expect(html).not.toContain("classDef cwAdded");
});
});