/** * A minimal word-level text diff for the version-history view (issue #42, * ADR 0013 documents that a diff on the derived Markdown is sufficient — no * structural diff UI in v1). Kept dependency-free and in `packages/shared` so * the web app renders it and it can be unit-tested in isolation. */ export type DiffSegmentType = 'equal' | 'added' | 'removed'; export interface DiffSegment { type: DiffSegmentType; value: string; } /** Split into alternating word and whitespace tokens so the text round-trips. */ function tokenize(text: string): string[] { return text.match(/\s+|\S+/g) ?? []; } function pushSegment(segments: DiffSegment[], type: DiffSegmentType, value: string): void { const last = segments[segments.length - 1]; if (last && last.type === type) { last.value += value; } else { segments.push({ type, value }); } } /** * Diff `before` (an older version) against `after` (the current text), * returning ordered segments. `removed` marks text only in `before`, `added` * marks text only in `after`, `equal` is shared — consecutive tokens of the * same kind are merged so the UI renders whole runs. * * Uses a standard longest-common-subsequence DP over tokens; page Markdown is * small enough that the O(n·m) table is not a concern. */ export function diffMarkdown(before: string, after: string): DiffSegment[] { const a = tokenize(before); const b = tokenize(after); const n = a.length; const m = b.length; // lcs[i][j] = length of the LCS of a[i..] and b[j..]. const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); for (let i = n - 1; i >= 0; i -= 1) { for (let j = m - 1; j >= 0; j -= 1) { lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!); } } const segments: DiffSegment[] = []; let i = 0; let j = 0; while (i < n && j < m) { if (a[i] === b[j]) { pushSegment(segments, 'equal', a[i]!); i += 1; j += 1; } else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) { pushSegment(segments, 'removed', a[i]!); i += 1; } else { pushSegment(segments, 'added', b[j]!); j += 1; } } while (i < n) { pushSegment(segments, 'removed', a[i]!); i += 1; } while (j < m) { pushSegment(segments, 'added', b[j]!); j += 1; } return segments; }