feat: authorAt + wordDiffByAuthor pure helpers (author-colored word diff w/ adjacency del)

This commit is contained in:
BenStullsBets
2026-06-26 13:34:12 -07:00
parent 94b1a9b0c2
commit 0ad040711f
2 changed files with 111 additions and 1 deletions
+70
View File
@@ -474,6 +474,76 @@ function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
.join("");
}
/** The author whose span covers `offset` (half-open [start,end)), or null. */
export function authorAt(offset: number, spans: AuthorSpan[]): AuthorKind | null {
for (const s of spans) if (offset >= s.start && offset < s.end) return s.author;
return null;
}
const insClass = (a: AuthorKind | null): string => `cw-ins-${a ?? "none"}`;
const delClass = (a: AuthorKind | null): string => `cw-del-${a ?? "none"}`;
/**
* Author-colored word diff for a changed PROSE block in the review.
* `afterStart` is the landed-text offset of `afterRaw[0]` so an inserted run's
* offset maps into `spans`. Insertions are colored by the author covering their
* offset; deletions inherit the author of the insertion they are paired with in
* the same contiguous change cluster (adjacency heuristic) — `cw-del-none` when a
* cluster has no insertion. Pure, deterministic.
*/
export function wordDiffByAuthor(
beforeRaw: string,
afterRaw: string,
afterStart: number,
spans: AuthorSpan[],
): string {
const parts = diffWordsWithSpace(beforeRaw, afterRaw);
// First pass: find each change cluster's insertion author (first added run).
// A cluster is a maximal run of added/removed parts bounded by unchanged parts.
let afterOff = afterStart;
const clusterAuthor: (AuthorKind | null)[] = []; // per part index, the cluster's del author
{
let off = afterStart;
let i = 0;
while (i < parts.length) {
const p = parts[i];
if (!p.added && !p.removed) {
clusterAuthor[i] = null;
off += p.value.length;
i++;
continue;
}
// a cluster: scan to its end, capturing the first added run's author
let j = i;
let author: AuthorKind | null = null;
let scanOff = off;
while (j < parts.length && (parts[j].added || parts[j].removed)) {
if (parts[j].added && author === null) author = authorAt(scanOff, spans);
if (parts[j].added) scanOff += parts[j].value.length;
j++;
}
for (let k = i; k < j; k++) clusterAuthor[k] = author;
off = scanOff;
i = j;
}
}
// Second pass: emit.
const out: string[] = [];
parts.forEach((p, i) => {
if (p.added) {
const a = authorAt(afterOff, spans);
out.push(`<ins class="${insClass(a)}">${p.value}</ins>`);
afterOff += p.value.length;
} else if (p.removed) {
out.push(`<del class="${delClass(clusterAuthor[i] ?? null)}">${p.value}</del>`);
} else {
out.push(p.value);
afterOff += p.value.length;
}
});
return out.join("");
}
export interface RenderOptions {
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
render?: (src: string) => string;