feat: re-emit changed mermaid diagrams through mermaidDiff in the built-in preview (plan T7 §2.6, INV-29 parity)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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; }
|
||||
|
||||
+14
-10
@@ -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";
|
||||
|
||||
|
||||
@@ -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 `<pre class="mermaid" style="all: unset;">${escapeMermaidSource(code)}</pre>`;
|
||||
const override = mermaidIdx < mermaidQueue.length ? mermaidQueue[mermaidIdx] : null;
|
||||
mermaidIdx++;
|
||||
const legend = override !== null ? MERMAID_LEGEND : "";
|
||||
return `<pre class="mermaid" style="all: unset;">${escapeMermaidSource(override ?? code)}</pre>${legend}`;
|
||||
}
|
||||
return baseHighlight?.(code, lang, attrs) ?? md.utils.escapeHtml(code);
|
||||
};
|
||||
|
||||
@@ -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 =
|
||||
'<div class="cw-mermaid-legend">' +
|
||||
'<span class="cw-leg cw-leg-add">added</span>' +
|
||||
'<span class="cw-leg cw-leg-chg">changed</span>' +
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user