F7: rendered track-changes markdown preview (#21) #23

Merged
benstull merged 12 commits from f7-rendered-preview into main 2026-06-11 15:50:34 +00:00
2 changed files with 48 additions and 15 deletions
Showing only changes of commit 6c3b137ebb - Show all commits
+33 -15
View File
@@ -145,40 +145,51 @@ function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
.join(""); .join("");
} }
function safeRender(src: string): string { export interface RenderOptions {
try { /** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
return md.render(src); render?: (src: string) => string;
} 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 { function defaultRender(src: string): string {
return md.render(src);
}
function chip(message: string): string {
return `<div class="cw-error">Could not render this block: ${md.utils.escapeHtml(message)}</div>`;
}
function renderOp(op: BlockOp, render: (src: string) => string): string {
const safe = (src: string): string => {
try {
return render(src);
} catch (err) {
return chip(err instanceof Error ? err.message : String(err));
}
};
let cls: string; let cls: string;
let inner: string; let inner: string;
let badge = ""; let badge = "";
switch (op.kind) { switch (op.kind) {
case "unchanged": case "unchanged":
cls = "cw-unchanged"; cls = "cw-unchanged";
inner = safeRender(op.block.raw); inner = safe(op.block.raw);
break; break;
case "added": case "added":
cls = "cw-added"; cls = "cw-added";
inner = safeRender(op.block.raw); inner = safe(op.block.raw);
if (op.block.type !== "prose") badge = '<span class="cw-badge">added</span>'; if (op.block.type !== "prose") badge = '<span class="cw-badge">added</span>';
break; break;
case "removed": case "removed":
cls = "cw-removed"; cls = "cw-removed";
inner = safeRender(op.block.raw); inner = safe(op.block.raw);
break; break;
case "changed": case "changed":
cls = "cw-changed"; cls = "cw-changed";
if (op.atomic) { if (op.atomic) {
inner = safeRender(op.block.raw); // the NEW block, whole (INV-23) inner = safe(op.block.raw); // the NEW block, whole (INV-23)
badge = '<span class="cw-badge">changed</span>'; badge = '<span class="cw-badge">changed</span>';
} else { } else {
inner = safeRender(wordMergedMarkdown(op.before.raw, op.block.raw)); inner = safe(wordMergedMarkdown(op.before.raw, op.block.raw));
} }
break; break;
} }
@@ -186,6 +197,13 @@ function renderOp(op: BlockOp): string {
} }
/** Pure entry point: annotated HTML body for the preview (INV-22). */ /** Pure entry point: annotated HTML body for the preview (INV-22). */
export function renderTrackChanges(baselineText: string, currentText: string): string { export function renderTrackChanges(
return diffBlocks(baselineText, currentText).map(renderOp).join("\n"); baselineText: string,
currentText: string,
opts: RenderOptions = {},
): string {
const render = opts.render ?? defaultRender;
return diffBlocks(baselineText, currentText)
.map((op) => renderOp(op, render))
.join("\n");
} }
+15
View File
@@ -122,3 +122,18 @@ describe("renderTrackChanges", () => {
expect(a).toBe(b); expect(a).toBe(b);
}); });
}); });
describe("error chip (PUC-6)", () => {
it("a block whose render throws becomes an error chip; the rest still renders", () => {
const throwing = (src: string) => {
if (src.includes("BOOM")) throw new Error("kaboom");
return `<p>${src}</p>`;
};
const html = renderTrackChanges("Good one.\n\nBOOM here.\n", "Good one.\n\nBOOM here.\n", {
render: throwing,
});
expect(html).toContain("cw-error");
expect(html).toContain("kaboom");
expect(html).toContain("<p>Good one.</p>"); // the healthy block still rendered
});
});