diff --git a/packages/app/src/components/markdown/fence/mermaid/segments.ts b/packages/app/src/components/markdown/fence/mermaid/segments.ts index 1958a3c016..dc21db3b1e 100644 --- a/packages/app/src/components/markdown/fence/mermaid/segments.ts +++ b/packages/app/src/components/markdown/fence/mermaid/segments.ts @@ -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; diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 60315ac460..068fc1f2f4 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -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]: "); }); 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]: "); +}); + +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); +}); diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index 97fe981fc8..057eebd7e3 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -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 = ( {segments.map((segment) => segment.kind === "mermaid" ? ( - + ) : (