F10: interactive track-changes review in the markdown preview (#29) #30
@@ -367,39 +367,6 @@ export function colorByAuthor(
|
||||
return sentinelsToSpans(render(injected));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure authorship render (INV-26/28): the CURRENT text with each F3-attributed
|
||||
* span colored by author. Prose blocks get inline `<span class="cw-by-*">`;
|
||||
* code/mermaid fences stay ATOMIC (INV-27) — an overlapping span yields a
|
||||
* block-level author badge, never inner sentinels. Deterministic.
|
||||
*/
|
||||
export function renderAuthorship(
|
||||
currentText: string,
|
||||
spans: AuthorSpan[],
|
||||
opts: RenderOptions = {},
|
||||
): string {
|
||||
const render = opts.render ?? defaultRender;
|
||||
const safe = (src: string): string => {
|
||||
try {
|
||||
return render(src);
|
||||
} catch (err) {
|
||||
return chip(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
return splitBlocksWithRanges(currentText)
|
||||
.map((b) => {
|
||||
const overlapping = spans.filter((s) => s.end > b.start && s.start < b.end);
|
||||
if (b.type !== "prose") {
|
||||
const badge = authorBadge(new Set(overlapping.map((s) => s.author)));
|
||||
const inner = safe(b.raw);
|
||||
if (!badge) return `<div class="cw-blk">${inner}</div>`;
|
||||
return `<div class="cw-blk ${badge.cls}"><span class="cw-badge">${badge.label}</span>${inner}</div>`;
|
||||
}
|
||||
return `<div class="cw-blk">${colorByAuthor(b.raw, b.start, overlapping, safe)}</div>`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Off-state body: the current buffer as plain markdown, no annotations (INV-33). */
|
||||
export function renderPlain(currentText: string, opts: RenderOptions = {}): string {
|
||||
const render = opts.render ?? defaultRender;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, test, expect } from "vitest";
|
||||
import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges, renderAuthorship, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
|
||||
import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
|
||||
|
||||
describe("splitBlocks", () => {
|
||||
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
||||
@@ -159,82 +159,6 @@ describe("splitBlocksWithRanges — block offsets align with the source string",
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderAuthorship", () => {
|
||||
const spanAt = (text: string, sub: string, author: "claude" | "human"): AuthorSpan => {
|
||||
const start = text.indexOf(sub);
|
||||
return { start, end: start + sub.length, author };
|
||||
};
|
||||
|
||||
it("empty spans → plain render (no author wrappers)", () => {
|
||||
const text = "# Hi\n\nplain paragraph.\n";
|
||||
const html = renderAuthorship(text, []);
|
||||
expect(html).not.toContain("cw-by-claude");
|
||||
expect(html).not.toContain("cw-by-human");
|
||||
expect(html).toContain("plain paragraph.");
|
||||
});
|
||||
|
||||
it("wraps a single Claude span inline", () => {
|
||||
const text = "The cat sat on the mat.\n";
|
||||
const html = renderAuthorship(text, [spanAt(text, "cat sat", "claude")]);
|
||||
expect(html).toContain('<span class="cw-by-claude">cat sat</span>');
|
||||
});
|
||||
|
||||
it("marks two authors in one paragraph at exact boundaries", () => {
|
||||
const text = "Alpha beta gamma.\n";
|
||||
const html = renderAuthorship(text, [
|
||||
spanAt(text, "Alpha", "human"),
|
||||
spanAt(text, "gamma", "claude"),
|
||||
]);
|
||||
expect(html).toContain('<span class="cw-by-human">Alpha</span>');
|
||||
expect(html).toContain('<span class="cw-by-claude">gamma</span>');
|
||||
});
|
||||
|
||||
it("handles two ADJACENT spans (one's end == next's start) in order", () => {
|
||||
const text = "ONETWO\n";
|
||||
const html = renderAuthorship(text, [
|
||||
{ start: 0, end: 3, author: "human" }, // ONE
|
||||
{ start: 3, end: 6, author: "claude" }, // TWO
|
||||
]);
|
||||
expect(html).toContain('<span class="cw-by-human">ONE</span><span class="cw-by-claude">TWO</span>');
|
||||
});
|
||||
|
||||
it("clips a span to its block (does not bleed across blocks)", () => {
|
||||
const text = "Para one.\n\nPara two.\n";
|
||||
const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]);
|
||||
expect(html).toContain('<span class="cw-by-claude">Para one.</span>');
|
||||
expect(html).toContain('<span class="cw-by-claude">Para two.</span>');
|
||||
});
|
||||
|
||||
it("a code fence overlapping a span gets a block badge, NOT inner sentinels (atomic)", () => {
|
||||
const text = "```js\nconst x = 1;\n```\n";
|
||||
const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]);
|
||||
expect(html).toContain("cw-by-claude");
|
||||
expect(html).toContain("cw-badge");
|
||||
expect(html).not.toMatch(/[\uE000-\uE003]/); // no sentinel leaked
|
||||
expect(html).toContain("const x = 1;");
|
||||
});
|
||||
|
||||
it("a mermaid fence authored by Claude renders as a diagram with a badge", () => {
|
||||
const text = "```mermaid\ngraph TD; A-->B;\n```\n";
|
||||
const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]);
|
||||
expect(html).toContain('pre class="mermaid"');
|
||||
expect(html).toContain("cw-by-claude");
|
||||
expect(html).toContain("cw-badge");
|
||||
});
|
||||
|
||||
it("never leaks a raw sentinel into prose output", () => {
|
||||
const text = "Some mixed text here.\n";
|
||||
const html = renderAuthorship(text, [spanAt(text, "mixed", "claude")]);
|
||||
expect(html).not.toMatch(/[\uE000-\uE003]/);
|
||||
});
|
||||
|
||||
it("is deterministic", () => {
|
||||
const text = "Stable input paragraph.\n";
|
||||
const spans: AuthorSpan[] = [spanAt(text, "input", "claude")];
|
||||
expect(renderAuthorship(text, spans)).toBe(renderAuthorship(text, spans));
|
||||
});
|
||||
});
|
||||
|
||||
describe("colorByAuthor", () => {
|
||||
test("colorByAuthor wraps human-authored prose in cw-by-human spans", () => {
|
||||
const raw = "hello world";
|
||||
|
||||
Reference in New Issue
Block a user