feat(f9): renderAuthorship — inline author spans + atomic fence badges (INV-26/27/28)

PUA sentinel injection through markdown-it; adjacent-span ordering handled;
code/mermaid fences atomic with a block-level author badge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-11 14:36:19 -07:00
parent 82d7c52983
commit 95eaf78891
2 changed files with 166 additions and 1 deletions
+77 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges } from "../src/trackChangesModel";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges, renderAuthorship, type AuthorSpan } from "../src/trackChangesModel";
describe("splitBlocks", () => {
it("splits prose paragraphs on blank lines, dropping empties", () => {
@@ -156,3 +156,79 @@ describe("splitBlocksWithRanges — block offsets align with the source string",
expect(text.slice(blocks[1].start, blocks[1].end)).toBe(blocks[1].raw);
});
});
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));
});
});