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 →
SRCfor 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);
+ });
+});