fix(markdown): retain diagram identity and reference scope

This commit is contained in:
Mohamed Boudra
2026-10-15 15:32:19 +02:00
parent 3d06f652b2
commit b782963970
3 changed files with 53 additions and 20 deletions
@@ -1,40 +1,54 @@
import { fromMarkdown } from "mdast-util-from-markdown";
import type { RootContent } from "mdast";
import type { Definition, RootContent } from "mdast";
interface Segment {
kind: "markdown" | "mermaid";
offset: number;
id: string;
source: string;
}
/** Native enriched-markdown has no fenced-block override. Only diagrams leave its engine. */
export function splitMermaidFences(markdown: string): Segment[] {
if (!/mermaid/i.test(markdown)) return [{ kind: "markdown", offset: 0, source: markdown }];
if (!/mermaid/i.test(markdown)) return [{ kind: "markdown", id: "0", source: markdown }];
const segments: Segment[] = [];
const definitions: Definition[] = [];
let cursor = 0;
function visit(node: RootContent): void {
if (node.type === "definition") definitions.push(node);
if (node.type === "code" && node.lang?.toLowerCase() === "mermaid" && node.position) {
const start = node.position.start.offset!;
const end = node.position.end.offset!;
if (start > cursor)
segments.push({ kind: "markdown", offset: cursor, source: markdown.slice(cursor, start) });
segments.push({ kind: "mermaid", offset: start, source: node.value });
segments.push({
kind: "markdown",
id: String(segments.length),
source: markdown.slice(cursor, start),
});
segments.push({ kind: "mermaid", id: String(segments.length), source: node.value });
cursor = end;
} else if ("children" in node) node.children.forEach(visit);
}
const tree = fromMarkdown(markdown);
tree.children.forEach(visit);
if (cursor < markdown.length || segments.length === 0)
segments.push({ kind: "markdown", offset: cursor, source: markdown.slice(cursor) });
segments.push({
kind: "markdown",
id: String(segments.length),
source: markdown.slice(cursor),
});
// Reference links keep their document-wide scope on both sides of a diagram.
if (segments.some((segment) => segment.kind === "mermaid")) {
const definitions = tree.children
.filter((node) => node.type === "definition")
.map((node) => markdown.slice(node.position!.start.offset, node.position!.end.offset))
const referenceSource = definitions
.map((node) => {
const label = node.identifier.replace(/[\\[\]]/g, "\\$&");
const url = node.url.replace(/[\\<>]/g, "\\$&");
const title = node.title ? ` "${node.title.replace(/[\\"]/g, "\\$&")}"` : "";
return `[${label}]: <${url}>${title}`;
})
.join("\n");
if (definitions)
if (referenceSource)
for (const segment of segments) {
if (segment.kind === "markdown") segment.source += `\n\n${definitions}`;
if (segment.kind === "markdown") segment.source += `\n\n${referenceSource}`;
}
}
return segments;
@@ -85,12 +85,12 @@ it("extracts only Mermaid fences for the native diagram host", async () => {
const source =
"First.\n\n```ts\nconst n = 1;\n```\n\n```mermaid\ngraph TD\nA-->B\n```\n\n> Last.";
expect(splitMermaidFences(source)).toEqual([
{ kind: "markdown", offset: 0, source: "First.\n\n```ts\nconst n = 1;\n```\n\n" },
{ kind: "mermaid", offset: 32, source: "graph TD\nA-->B" },
{ kind: "markdown", offset: 61, source: "\n\n> Last." },
{ kind: "markdown", id: "0", source: "First.\n\n```ts\nconst n = 1;\n```\n\n" },
{ kind: "mermaid", id: "1", source: "graph TD\nA-->B" },
{ kind: "markdown", id: "2", source: "\n\n> Last." },
]);
expect(splitMermaidFences("```md\n```mermaid\n```")).toEqual([
{ kind: "markdown", offset: 0, source: "```md\n```mermaid\n```" },
{ kind: "markdown", id: "0", source: "```md\n```mermaid\n```" },
]);
});
@@ -100,12 +100,31 @@ it("keeps reference links available across native Mermaid boundaries", async ()
"[Before][file]\n\n```mermaid\ngraph TD\nA-->B\n```\n\n[After][file]\n\n[file]: src/file.ts",
);
for (const part of parts.filter((candidate) => candidate.kind === "markdown"))
expect(part.source).toContain("[file]: src/file.ts");
expect(part.source).toContain("[file]: <src/file.ts>");
});
it("recognizes mixed-case Mermaid fence info and incomplete streaming fences", async () => {
const { splitMermaidFences } = await import("./fence/mermaid/segments");
expect(splitMermaidFences("```Mermaid title\ngraph TD\nA-->B")).toEqual([
{ kind: "mermaid", offset: 0, source: "graph TD\nA-->B" },
{ kind: "mermaid", id: "0", source: "graph TD\nA-->B" },
]);
});
it("carries reference definitions nested in quotes across a diagram", async () => {
const { splitMermaidFences } = await import("./fence/mermaid/segments");
const parts = splitMermaidFences(
"[open][file]\n\n```mermaid\ngraph TD\nA-->B\n```\n\n> [file]: src/file.ts",
);
expect(parts[0].source).toContain("[file]: <src/file.ts>");
});
it("keeps diagram identity when an earlier workspace image resolves", async () => {
const { splitMermaidFences } = await import("./fence/mermaid/segments");
const source = "![image](a.png)\n\n```mermaid\ngraph TD\nA-->B\n```";
const before = splitMermaidFences(source).find((part) => part.kind === "mermaid");
const after = splitMermaidFences(
source.replace("a.png", "file:///long/cache/path/image.png"),
).find((part) => part.kind === "mermaid");
expect(before).toHaveProperty("id");
expect(after?.id).toBe(before?.id);
});
@@ -158,17 +158,17 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
const closeImage = useStableEvent(() => setImage(null));
const segments = useMemo(
() =>
isWeb ? [{ kind: "markdown", offset: 0, source: markdown }] : splitMermaidFences(markdown),
isWeb ? [{ kind: "markdown", id: "0", source: markdown }] : splitMermaidFences(markdown),
[markdown],
);
const content = (
<MermaidStreamingContext.Provider value={streaming}>
{segments.map((segment) =>
segment.kind === "mermaid" ? (
<MermaidFence key={segment.offset} code={segment.source} />
<MermaidFence key={`${segment.kind}:${segment.id}`} code={segment.source} />
) : (
<ThemedMarkdown
key={segment.offset}
key={`${segment.kind}:${segment.id}`}
markdown={segment.source}
containerStyle={isWeb ? WEB_CONTAINER : undefined}
uniProps={compact ? compactStyle : regularStyle}