From 247a450e1069a3e045aac3daa5644654cd92e74d Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 08:42:11 -0700 Subject: [PATCH] feat(f7): block splitter for the render engine (#21) Co-Authored-By: Claude Opus 4.8 --- src/trackChangesModel.ts | 71 ++++++++++++++++++++++++++++++++++ test/trackChangesModel.test.ts | 29 ++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 src/trackChangesModel.ts create mode 100644 test/trackChangesModel.test.ts diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts new file mode 100644 index 0000000..93a6d59 --- /dev/null +++ b/src/trackChangesModel.ts @@ -0,0 +1,71 @@ +/** + * 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 ``/`` 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; +} diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts new file mode 100644 index 0000000..4f0b188 --- /dev/null +++ b/test/trackChangesModel.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { splitBlocks } from "../src/trackChangesModel"; + +describe("splitBlocks", () => { + it("splits prose paragraphs on blank lines, dropping empties", () => { + const blocks = splitBlocks("# Heading\n\nFirst para.\n\nSecond para.\n"); + expect(blocks.map((b) => b.type)).toEqual(["prose", "prose", "prose"]); + expect(blocks[0].raw).toBe("# Heading"); + expect(blocks[2].raw).toBe("Second para."); + }); + + it("keeps a fenced code block whole and tags it `code`", () => { + const text = "Intro.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nOutro.\n"; + const blocks = splitBlocks(text); + expect(blocks.map((b) => b.type)).toEqual(["prose", "code", "prose"]); + expect(blocks[1].raw).toBe("```ts\nconst a = 1;\n\nconst b = 2;\n```"); + }); + + it("tags a mermaid fence `mermaid`", () => { + const blocks = splitBlocks("```mermaid\nflowchart LR\n a-->b\n```\n"); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("mermaid"); + }); + + it("normalizes the key (whitespace-collapsed) for matching", () => { + const blocks = splitBlocks("Hello world\n"); + expect(blocks[0].key).toBe("hello world"); + }); +});