feat(f7): render annotated track-changes HTML (#21)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-11 08:43:38 -07:00
parent 6ffbf67871
commit 3b0ec4f53e
2 changed files with 115 additions and 1 deletions
+68
View File
@@ -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 → <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");
}