From 705de3169be169ed282d82e20a198781b368b19e Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 15:08:46 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20re-emit=20changed=20mermaid=20diagrams?= =?UTF-8?q?=20through=20mermaidDiff=20in=20the=20built-in=20preview=20(pla?= =?UTF-8?q?n=20T7=20=C2=A72.6,=20INV-29=20parity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 7 scoped out reusing the F7.1 (#22) intra-diagram mermaid diff in the built-in Markdown preview's mermaid fence rendering; Task 8 then deleted the webview that still had it, leaving mermaidDiff.ts/mermaidFlowchartDiff.ts/ mermaidSequenceDiff.ts production-dead. Wires diffMermaid into cowritingMarkdownItPlugin's options.highlight override via a per-render mermaid-fence queue (built in the core rule where state.env is visible, consumed by highlight in fence-encounter order — highlight itself has no env param) so a mermaid fence changed since the F6/F7 baseline is re-emitted with classDef/class/linkStyle styling directives + legend before mermaid.js ever sees it, reusing diffBlocks' baseline/current block pairing (same pairing renderReview used). Unchanged diagrams, disabled annotations, no baseline, and pinned-zero-diff all pass through verbatim. Ports the substance of the 6 mermaid-diff assertions Task 8 deleted with the webview's E2E suite as pure unit tests in previewAnnotations.test.ts instead (DOM-free, source-level). Co-Authored-By: Claude Fable 5 --- media/preview-annotations.css | 12 ++++++ media/preview-mermaid.ts | 24 ++++++----- src/previewAnnotations.ts | 71 ++++++++++++++++++++++++++++++++- src/trackChangesModel.ts | 17 ++++++-- test/previewAnnotations.test.ts | 59 +++++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 16 deletions(-) diff --git a/media/preview-annotations.css b/media/preview-annotations.css index 9123dc5..f990d84 100644 --- a/media/preview-annotations.css +++ b/media/preview-annotations.css @@ -41,3 +41,15 @@ body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-del { background: rgba(248, 81, 73, 0.11); text-decoration-color: #f85149; } + +/* + * F7.1 (#22) intra-diagram mermaid diff legend, shown beneath a mermaid fence + * `previewAnnotations.ts` re-emitted through `mermaidDiff.ts` (Task 7 §2.6 + * parity — same legend markup + colors as the sunset webview's + * `media/preview.css`). + */ +.cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; } +.cw-mermaid-legend .cw-leg { padding: 0 0.4em; border-radius: 3px; border: 1px solid; } +.cw-mermaid-legend .cw-leg-add { color: #2ea043; border-color: #2ea043; } +.cw-mermaid-legend .cw-leg-chg { color: #d29922; border-color: #d29922; } +.cw-mermaid-legend .cw-leg-rem { color: #808080; border-color: #808080; border-style: dashed; } diff --git a/media/preview-mermaid.ts b/media/preview-mermaid.ts index 221b176..320d209 100644 --- a/media/preview-mermaid.ts +++ b/media/preview-mermaid.ts @@ -16,16 +16,20 @@ * in-place content updates that don't reload the script (mirrors the proven * `bierner.markdown-mermaid` extension's own approach). * - * SCOPE NOTE (Q4 finding, recorded per the migration plan's Task 7 Step 2.6): - * this renders the CURRENT diagram only — it does NOT re-emit a changed - * diagram through `mermaidDiff.ts`'s intra-diagram diff/legend augmentation - * (the F7.1 feature the sealed webview preview has). That augmentation needs a - * block-level (not word-hunk-level) diff pass wired into `annotateSource`, - * which was judged out of scope for this increment — it cannot be verified by - * the automated E2E harness (the built-in preview's DOM/CSP sandbox isn't - * queryable from a host test) and shipping it unverified risked a silent - * regression. Basic mermaid rendering (this file) IS wired and is exercised by - * the same proven pattern as the real `bierner.markdown-mermaid` extension. + * RESOLVED SCOPE NOTE (Q4 finding, migration plan Task 7 Step 2.6; wired in a + * cross-task review follow-up): this script itself only turns `pre.mermaid` + * nodes into diagrams — it never sees a diff. The intra-diagram diff/legend + * augmentation (F7.1, INV-29..31) happens one step earlier, source-side, + * where it CAN be unit-tested without a DOM: `previewAnnotations.ts`'s + * `cowritingMarkdownItPlugin` re-emits a CHANGED mermaid fence's source + * through `mermaidDiff.ts` (`buildMermaidQueue`, block-level `diffBlocks` + * pairing against the F6/F7 baseline) before this script ever runs, so the + * `pre.mermaid` node this script hands to `mermaid.run` already carries the + * `classDef`/`class`/`linkStyle` styling directives — this script needn't (and + * can't, DOM-less-ly) know a diagram changed at all. Basic mermaid rendering + * (this file) is exercised by the same proven pattern as the real + * `bierner.markdown-mermaid` extension; the augmentation upstream is exercised + * by pure unit tests (`test/previewAnnotations.test.ts`). */ import mermaid from "mermaid"; diff --git a/src/previewAnnotations.ts b/src/previewAnnotations.ts index c9c6b6b..675dd4d 100644 --- a/src/previewAnnotations.ts +++ b/src/previewAnnotations.ts @@ -35,9 +35,13 @@ * `lastActiveCoedited`). */ import type MarkdownIt from "markdown-it"; +import { diffMermaid } from "./mermaidDiff"; import { + diffBlocks, injectSentinels, landedTextOf, + MERMAID_LEGEND, + mermaidFenceBody, sentinelsToSpans, splitBlocksWithRanges, wordEditHunks, @@ -151,6 +155,52 @@ export function annotateSource(src: string, inputs: AnnotationInputs): string { return injectAllSentinels(text, marks); } +/** + * Per-render queue of mermaid-fence overrides, one entry per ```mermaid fence + * the CURRENT (landed) text contains, in document order — the same order + * `options.highlight` encounters them while markdown-it renders fence tokens + * (Task 7 §2.6 parity: reuses `diffBlocks`' baseline/current block pairing, + * the SAME pairing `renderReview`/`renderOp` used in the sunset webview, so a + * changed mermaid fence is re-emitted through `diffMermaid` exactly as it was + * there — INV-23 fences stay atomic, INV-29..31 styling directives). An entry + * is the augmented source string when the fence is a `changed` atomic mermaid + * op with a diffable before/current pair, else `null` (unchanged/added fence, + * or a fallback pair `diffMermaid` couldn't diff — render the fence verbatim). + * `[]` when there is no baseline to diff against, annotations are disabled, or + * the landed text equals the baseline (covers the #48 pinned-zero-diff case + * too, since `diffBlocks` on identical text yields no `changed` ops). + * + * Diffed against `landedText` (proposals reverted), matching the "committed + * change since baseline" boundary `annotateSource`'s own `wordEditHunks(src, + * baselineText)` gate already draws (a pending, not-yet-accepted proposal + * isn't a landed change) — a fence a pending proposal's anchor overlaps is + * left unaugmented, same as the rest of that gate. This assumes fence COUNT + * and ORDER match between `src` (what markdown-it actually parses fences + * from) and `landedText` (what this function diffs) — true whenever no + * pending proposal adds/removes a whole mermaid block; a rare edge case + * accepted as a known scope boundary (mirrors the built-in preview's other + * documented Q4/scope notes), not something the built-in preview's + * proposal-block accounting handles elsewhere either. + * + * Pure, vscode-free (INV-6). + */ +function buildMermaidQueue(src: string, inputs: AnnotationInputs): (string | null)[] { + if (!inputs.enabled || inputs.baselineText === undefined) return []; + const baselineText = inputs.baselineText; + const landedText = landedTextOf(src, inputs.proposals ?? []); + if (landedText === baselineText) return []; + return diffBlocks(baselineText, landedText) + .filter((op) => op.kind !== "removed" && op.block.type === "mermaid") + .map((op) => { + if (op.kind !== "changed") return null; // unchanged/added: render verbatim + const curBody = mermaidFenceBody(op.block.raw); + const beforeBody = op.before.type === "mermaid" ? mermaidFenceBody(op.before.raw) : null; + if (curBody === null || beforeBody === null) return null; + const result = diffMermaid(beforeBody, curBody); + return result.kind === "augmented" ? result.source : null; // INV-30 fallback: verbatim + }); +} + /** * markdown-it plugin factory: the host supplies `AnnotationInputs` per render * (`host.inputsFor(env)`; `undefined` = no coediting context for this render — @@ -161,14 +211,28 @@ export function annotateSource(src: string, inputs: AnnotationInputs): string { * (Q4/Task 7 Step 2.6) via `options.highlight` — the same extension point the * built-in preview's own fence rule already calls for syntax highlighting — so * the bundled `media/preview-mermaid.js` previewScript has something to run - * mermaid over (the built-in preview does not render mermaid on its own). + * mermaid over (the built-in preview does not render mermaid on its own). A + * CHANGED mermaid fence's source is re-emitted through `diffMermaid` first + * (`buildMermaidQueue`, Task 7 §2.6 parity) so mermaid renders the intra- + * diagram diff (INV-29..31), not just the current diagram. `options.highlight` + * has no `env`/`state` parameter of its own, so the per-render queue is + * threaded through a closure variable set by the core rule (which DOES see + * `state.env`) and consumed in fence-encounter order by `highlight` — + * markdown-it's `parse` (core rules) always completes before `renderer.render` + * invokes `highlight`, and a single md instance never renders concurrently, so + * this is safe. */ export function cowritingMarkdownItPlugin( md: MarkdownIt, host: { inputsFor(env: unknown): AnnotationInputs | undefined }, ): MarkdownIt { + let mermaidQueue: (string | null)[] = []; + let mermaidIdx = 0; + md.core.ruler.before("normalize", "cowriting-annotate", (state) => { const inputs = host.inputsFor(state.env); + mermaidQueue = inputs ? buildMermaidQueue(state.src, inputs) : []; + mermaidIdx = 0; if (!inputs) return; state.src = annotateSource(state.src, inputs); }); @@ -179,7 +243,10 @@ export function cowritingMarkdownItPlugin( const baseHighlight = md.options.highlight; md.options.highlight = (code, lang, attrs) => { if (lang?.trim().toLowerCase() === "mermaid") { - return `
${escapeMermaidSource(code)}
`; + const override = mermaidIdx < mermaidQueue.length ? mermaidQueue[mermaidIdx] : null; + mermaidIdx++; + const legend = override !== null ? MERMAID_LEGEND : ""; + return `
${escapeMermaidSource(override ?? code)}
${legend}`; } return baseHighlight?.(code, lang, attrs) ?? md.utils.escapeHtml(code); }; diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 7a6316a..9b9ab25 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -469,8 +469,13 @@ md.renderer.rules.fence = (tokens, idx, options, env, self) => { return defaultFence(tokens, idx, options, env, self); }; -/** Inner source of a ```mermaid fence (drops the fence lines), or null. */ -function mermaidFenceBody(raw: string): string | null { +/** + * Inner source of a ```mermaid fence (drops the fence lines), or null. Exported + * so `previewAnnotations.ts` can pair a `diffBlocks` mermaid `changed` op's + * before/current fence bodies for `diffMermaid` without reinventing this + * extraction (Task 7 §2.6 parity). + */ +export function mermaidFenceBody(raw: string): string | null { const lines = raw.split(/\r?\n/); const open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/); if (!open || open[3]?.toLowerCase() !== "mermaid") return null; @@ -479,8 +484,12 @@ function mermaidFenceBody(raw: string): string | null { return body.join("\n"); } -/** F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block. */ -const MERMAID_LEGEND = +/** + * F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block. + * Exported for reuse by `previewAnnotations.ts`'s built-in-preview mermaid + * diff wiring (Task 7 §2.6 parity — same legend markup, both surfaces). + */ +export const MERMAID_LEGEND = '
' + 'added' + 'changed' + diff --git a/test/previewAnnotations.test.ts b/test/previewAnnotations.test.ts index 0579cd9..ee71f95 100644 --- a/test/previewAnnotations.test.ts +++ b/test/previewAnnotations.test.ts @@ -73,3 +73,62 @@ describe("preview annotations (PUC-3/D21)", () => { expect(html).not.toContain("cw-"); }); }); + +// Task 7 §2.6 / cross-task review follow-up (plan-mandated, INV-29..31): the +// built-in preview's mermaid fence rendering reuses the F7.1 (#22) pure +// re-emit, same as the sunset webview did — a changed mermaid diagram gets +// intra-diagram change styling, not just the plain current diagram. Ports the +// substance of the E2E assertions Task 8 deleted with the webview +// (`git show 2170a0d^:test/e2e/suite/trackChangesPreview.test.ts`, "a changed +// flowchart augments the emitted mermaid source (#22, INV-29)") as pure unit +// tests here instead — no DOM/webview needed to verify SOURCE augmentation. +describe("intra-diagram mermaid diff in the built-in preview (plan T7 §2.6, INV-29)", () => { + const flowchartBefore = "```mermaid\nflowchart LR\n a --> b\n```\n"; + const flowchartAfter = "```mermaid\nflowchart LR\n a --> b\n a --> c\n```\n"; + + it("a changed flowchart fence is re-emitted with cwAdded styling + a legend", () => { + const html = render(flowchartAfter, { ...base, baselineText: flowchartBefore, spans: [] }); + expect(html).toMatch(/classDef cwAdded/); + expect(html).toMatch(/class\s+c\s+cwAdded/); + expect(html).toContain("cw-mermaid-legend"); + expect(html).toContain('class="mermaid"'); + }); + + it("an unchanged mermaid fence passes through byte-identical (no diff markup)", () => { + const html = render(flowchartBefore, { ...base, baselineText: flowchartBefore, spans: [] }); + expect(html).not.toContain("cwAdded"); + expect(html).not.toContain("cw-mermaid-legend"); + expect(html).toContain("flowchart LR"); + expect(html).toContain("a --> b"); + }); + + it("annotations disabled: a changed mermaid fence renders the plain current diagram", () => { + const html = render(flowchartAfter, { ...base, enabled: false, baselineText: flowchartBefore, spans: [] }); + expect(html).not.toContain("cwAdded"); + expect(html).not.toContain("cw-mermaid-legend"); + expect(html).toContain("a --> c"); + }); + + it("pinned baseline with zero landed diff: mermaid fence renders the plain current diagram (INV-33/#48 parity)", () => { + const html = render(flowchartAfter, { + ...base, baselineReason: "pinned", baselineText: flowchartAfter, spans: [], + }); + expect(html).not.toContain("cwAdded"); + expect(html).not.toContain("cw-mermaid-legend"); + }); + + it("no baseline (no coediting context established): mermaid fence renders the plain current diagram", () => { + const html = render(flowchartAfter, { ...base, baselineText: undefined, spans: [] }); + expect(html).not.toContain("cwAdded"); + expect(html).not.toContain("cw-mermaid-legend"); + }); + + it("a non-mermaid changed fence is untouched by the mermaid queue (fence-kind gate)", () => { + const before = "```js\nconst a = 1;\n```\n"; + const after = "```js\nconst a = 2;\n```\n"; + const html = render(after, { ...base, baselineText: before, spans: [] }); + expect(html).not.toContain("cwAdded"); + expect(html).not.toContain("cw-mermaid-legend"); + expect(html).toContain('class="language-js"'); + }); +});