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
+89
View File
@@ -266,6 +266,95 @@ function renderOp(op: BlockOp, render: (src: string) => string): string {
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). */
export function renderTrackChanges(
baselineText: string,