F7: rendered track-changes markdown preview (#21) #23
@@ -121,3 +121,71 @@ export function diffBlocks(baselineText: string, currentText: string): BlockOp[]
|
|||||||
}
|
}
|
||||||
return ops;
|
return ops;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
|
||||||
|
// mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all
|
||||||
|
// other fences fall through to markdown-it's default (escaped <pre><code>).
|
||||||
|
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 `<pre class="mermaid">${md.utils.escapeHtml(tokens[idx].content.replace(/\n$/, ""))}</pre>\n`;
|
||||||
|
}
|
||||||
|
return defaultFence(tokens, idx, options, env, self);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Build a markdown string with inline <ins>/<del> from a word-level prose diff. */
|
||||||
|
function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
|
||||||
|
return diffWords(beforeRaw, afterRaw)
|
||||||
|
.map((part) => {
|
||||||
|
if (part.added) return `<ins>${part.value}</ins>`;
|
||||||
|
if (part.removed) return `<del>${part.value}</del>`;
|
||||||
|
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 `<div class="cw-error">Could not render this block: ${md.utils.escapeHtml(message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<span class="cw-badge">added</span>';
|
||||||
|
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 = '<span class="cw-badge">changed</span>';
|
||||||
|
} else {
|
||||||
|
inner = safeRender(wordMergedMarkdown(op.before.raw, op.block.raw));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { splitBlocks, diffBlocks } from "../src/trackChangesModel";
|
import { splitBlocks, diffBlocks, renderTrackChanges } from "../src/trackChangesModel";
|
||||||
|
|
||||||
describe("splitBlocks", () => {
|
describe("splitBlocks", () => {
|
||||||
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
||||||
@@ -76,3 +76,49 @@ describe("diffBlocks", () => {
|
|||||||
expect(k).toContain("unchanged");
|
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 <ins>/<del> for a prose modification", () => {
|
||||||
|
const html = renderTrackChanges("The quick fox.\n", "The slow fox.\n");
|
||||||
|
expect(html).toContain("<ins>");
|
||||||
|
expect(html).toContain("<del>");
|
||||||
|
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("<ins>");
|
||||||
|
expect(html).not.toContain("<del>");
|
||||||
|
expect(html).toContain("cw-badge");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits <pre class="mermaid"> 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('<pre class="mermaid">');
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user