6ffbf67871
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
124 lines
4.3 KiB
TypeScript
124 lines
4.3 KiB
TypeScript
/**
|
|
* trackChangesModel — F7 pure, vscode-free render engine (spec §6.2, INV-22/23).
|
|
*
|
|
* `renderTrackChanges(baselineText, currentText)` returns the annotated HTML body
|
|
* for the preview webview: a block-level diff (LCS over normalized blocks) with
|
|
* word-level `<ins>`/`<del>` refinement inside changed PROSE blocks, and CODE /
|
|
* MERMAID fences kept ATOMIC (INV-23 — never word-refined, never partially
|
|
* rendered). Deterministic: same inputs → identical HTML (INV-22). No vscode, no
|
|
* DOM — `markdown-it` and `diff` are libraries; mermaid runs later in the webview.
|
|
*/
|
|
import MarkdownIt from "markdown-it";
|
|
import { diffArrays, diffWords } from "diff";
|
|
|
|
export type BlockType = "prose" | "code" | "mermaid";
|
|
|
|
export interface Block {
|
|
/** Verbatim source of the block (no surrounding blank lines). */
|
|
raw: string;
|
|
/** Normalized match key: lowercased, whitespace-collapsed. */
|
|
key: string;
|
|
type: BlockType;
|
|
}
|
|
|
|
function normalize(raw: string): string {
|
|
return raw.trim().replace(/\s+/g, " ").toLowerCase();
|
|
}
|
|
|
|
function makeBlock(raw: string, type: BlockType): Block {
|
|
return { raw, key: normalize(raw), type };
|
|
}
|
|
|
|
/** Split markdown into top-level blocks; fenced code/mermaid stay whole. */
|
|
export function splitBlocks(text: string): Block[] {
|
|
const lines = text.split(/\r?\n/);
|
|
const blocks: Block[] = [];
|
|
let buf: string[] = [];
|
|
const flushProse = () => {
|
|
const raw = buf.join("\n");
|
|
if (raw.trim()) blocks.push(makeBlock(raw, "prose"));
|
|
buf = [];
|
|
};
|
|
let i = 0;
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
|
|
if (fence) {
|
|
flushProse();
|
|
const marker = fence[2][0]; // ` or ~
|
|
const info = fence[3].trim().split(/\s+/)[0].toLowerCase();
|
|
const fenceLines = [line];
|
|
i++;
|
|
while (i < lines.length) {
|
|
fenceLines.push(lines[i]);
|
|
const closed = lines[i].trim().startsWith(marker.repeat(3));
|
|
i++;
|
|
if (closed) break;
|
|
}
|
|
blocks.push(makeBlock(fenceLines.join("\n"), info === "mermaid" ? "mermaid" : "code"));
|
|
continue;
|
|
}
|
|
if (line.trim() === "") {
|
|
flushProse();
|
|
i++;
|
|
continue;
|
|
}
|
|
buf.push(line);
|
|
i++;
|
|
}
|
|
flushProse();
|
|
return blocks;
|
|
}
|
|
|
|
export type BlockOp =
|
|
| { kind: "unchanged"; block: Block }
|
|
| { kind: "added"; block: Block }
|
|
| { kind: "removed"; block: Block }
|
|
| { kind: "changed"; block: Block; before: Block; atomic: boolean };
|
|
|
|
/**
|
|
* Diff two block sequences. Matching is by normalized key (jsdiff `diffArrays`);
|
|
* adjacent removed-then-added runs are paired element-wise into `changed` ops, the
|
|
* surplus staying `removed` / `added`. A `changed` op is ATOMIC (INV-23) when
|
|
* either side is a code/mermaid fence — rendered whole, never word-refined.
|
|
*/
|
|
export function diffBlocks(baselineText: string, currentText: string): BlockOp[] {
|
|
const before = splitBlocks(baselineText);
|
|
const after = splitBlocks(currentText);
|
|
const changes = diffArrays(
|
|
before.map((b) => b.key),
|
|
after.map((b) => b.key),
|
|
);
|
|
|
|
const ops: BlockOp[] = [];
|
|
let bi = 0; // index into `before`
|
|
let ci = 0; // index into `after`
|
|
for (let n = 0; n < changes.length; n++) {
|
|
const ch = changes[n];
|
|
const count = ch.count ?? ch.value.length;
|
|
if (!ch.added && !ch.removed) {
|
|
for (let k = 0; k < count; k++) ops.push({ kind: "unchanged", block: after[ci++] });
|
|
bi += count;
|
|
continue;
|
|
}
|
|
if (ch.removed) {
|
|
const next = changes[n + 1];
|
|
const addCount = next?.added ? (next.count ?? next.value.length) : 0;
|
|
const paired = Math.min(count, addCount);
|
|
for (let k = 0; k < paired; k++) {
|
|
const beforeBlk = before[bi++];
|
|
const afterBlk = after[ci++];
|
|
const atomic = beforeBlk.type !== "prose" || afterBlk.type !== "prose";
|
|
ops.push({ kind: "changed", block: afterBlk, before: beforeBlk, atomic });
|
|
}
|
|
for (let k = paired; k < count; k++) ops.push({ kind: "removed", block: before[bi++] });
|
|
for (let k = paired; k < addCount; k++) ops.push({ kind: "added", block: after[ci++] });
|
|
if (next?.added) n++; // consumed the paired added run
|
|
continue;
|
|
}
|
|
// a lone added run (no preceding removed)
|
|
for (let k = 0; k < count; k++) ops.push({ kind: "added", block: after[ci++] });
|
|
}
|
|
return ops;
|
|
}
|