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:
@@ -266,6 +266,95 @@ function renderOp(op: BlockOp, render: (src: string) => string): string {
|
|||||||
return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
|
return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthorKind = "claude" | "human";
|
||||||
|
export interface AuthorSpan {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
author: AuthorKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private-Use-Area sentinels (never appear in real content; markdown-it passes
|
||||||
|
// them through as plain text). Paired open/close per author.
|
||||||
|
const SENT = {
|
||||||
|
claude: { open: "", close: "" },
|
||||||
|
human: { open: "", close: "" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function isCloseSentinel(m: string): boolean {
|
||||||
|
return m === SENT.claude.close || m === SENT.human.close;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorBadge(authors: Set<AuthorKind>): { cls: string; label: string } | null {
|
||||||
|
if (authors.size === 0) return null;
|
||||||
|
if (authors.size > 1) return { cls: "cw-mixed", label: "mixed" };
|
||||||
|
const only = [...authors][0];
|
||||||
|
return only === "claude"
|
||||||
|
? { cls: "cw-by-claude", label: "Claude" }
|
||||||
|
: { cls: "cw-by-human", label: "You" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */
|
||||||
|
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string {
|
||||||
|
const inserts: { at: number; marker: string }[] = [];
|
||||||
|
for (const s of spans) {
|
||||||
|
const lo = Math.max(0, s.start - blockStart);
|
||||||
|
const hi = Math.min(raw.length, s.end - blockStart);
|
||||||
|
if (hi <= lo) continue;
|
||||||
|
inserts.push({ at: lo, marker: SENT[s.author].open });
|
||||||
|
inserts.push({ at: hi, marker: SENT[s.author].close });
|
||||||
|
}
|
||||||
|
// Apply high offset → low so earlier offsets stay valid. At an equal offset
|
||||||
|
// (one span's close == the next's open), apply opens BEFORE closes so the
|
||||||
|
// close ends up left of the open: "…</span><span>…" for adjacent spans.
|
||||||
|
inserts.sort((a, b) => b.at - a.at || (isCloseSentinel(a.marker) ? 1 : -1));
|
||||||
|
let out = raw;
|
||||||
|
for (const ins of inserts) out = out.slice(0, ins.at) + ins.marker + out.slice(ins.at);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the rendered sentinels with author <span> tags. */
|
||||||
|
function sentinelsToSpans(html: string): string {
|
||||||
|
return html
|
||||||
|
.split(SENT.claude.open).join('<span class="cw-by-claude">')
|
||||||
|
.split(SENT.claude.close).join("</span>")
|
||||||
|
.split(SENT.human.open).join('<span class="cw-by-human">')
|
||||||
|
.split(SENT.human.close).join("</span>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>`;
|
||||||
|
}
|
||||||
|
const injected = injectSentinels(b.raw, b.start, overlapping);
|
||||||
|
return `<div class="cw-blk">${sentinelsToSpans(safe(injected))}</div>`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
/** 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(
|
export function renderTrackChanges(
|
||||||
baselineText: string,
|
baselineText: string,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
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", () => {
|
describe("splitBlocks", () => {
|
||||||
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
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);
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user