diff --git a/packages/app/src/components/markdown/fence/mermaid/segments.ts b/packages/app/src/components/markdown/fence/mermaid/segments.ts index dc21db3b1e..be2f229051 100644 --- a/packages/app/src/components/markdown/fence/mermaid/segments.ts +++ b/packages/app/src/components/markdown/fence/mermaid/segments.ts @@ -11,10 +11,11 @@ interface Segment { export function splitMermaidFences(markdown: string): Segment[] { if (!/mermaid/i.test(markdown)) return [{ kind: "markdown", id: "0", source: markdown }]; const segments: Segment[] = []; - const definitions: Definition[] = []; + const definitions = new Map(); let cursor = 0; function visit(node: RootContent): void { - if (node.type === "definition") definitions.push(node); + if (node.type === "definition" && !definitions.has(node.identifier)) + definitions.set(node.identifier, node); if (node.type === "code" && node.lang?.toLowerCase() === "mermaid" && node.position) { const start = node.position.start.offset!; const end = node.position.end.offset!; @@ -36,9 +37,10 @@ export function splitMermaidFences(markdown: string): Segment[] { id: String(segments.length), source: markdown.slice(cursor), }); - // Reference links keep their document-wide scope on both sides of a diagram. + // Prepend the document's first definitions after splitting, so fragment-local + // duplicates cannot override them. Original source slices and fence offsets stay intact. if (segments.some((segment) => segment.kind === "mermaid")) { - const referenceSource = definitions + const referenceSource = [...definitions.values()] .map((node) => { const label = node.identifier.replace(/[\\[\]]/g, "\\$&"); const url = node.url.replace(/[\\<>]/g, "\\$&"); @@ -48,7 +50,7 @@ export function splitMermaidFences(markdown: string): Segment[] { .join("\n"); if (referenceSource) for (const segment of segments) { - if (segment.kind === "markdown") segment.source += `\n\n${referenceSource}`; + if (segment.kind === "markdown") segment.source = `${referenceSource}\n\n${segment.source}`; } } return segments; diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 560ae23973..9d47a2c65d 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -369,3 +369,25 @@ it("retries transient image decoding failures instead of caching an unavailable expect(attempts).toBe(2); client.clear(); }); + +it("keeps the document's first reference definition across every native Mermaid segment", async () => { + const { fromMarkdown } = await import("mdast-util-from-markdown"); + const { splitMermaidFences } = await import("./fence/mermaid/segments"); + const before = '[first]: /repo/first.ts "Original"\n\n'; + const after = '\n\n[open][first]\n\n[first]: /repo/second.ts "Later"'; + const source = `${before}\`\`\`mermaid\ngraph TD\nA-->B\n\`\`\`${after}`; + const firstDefinition = (markdown: string) => + fromMarkdown(markdown).children.find((node) => node.type === "definition"); + const canonical = firstDefinition(source); + const segments = splitMermaidFences(source); + for (const segment of segments.filter((candidate) => candidate.kind === "markdown")) { + expect(firstDefinition(segment.source)).toMatchObject({ + identifier: canonical?.identifier, + url: canonical?.url, + title: canonical?.title, + }); + } + expect(segments[0].source.endsWith(before)).toBe(true); + expect(segments[2].source.endsWith(after)).toBe(true); + expect(segments[1]).toEqual({ kind: "mermaid", id: "1", source: "graph TD\nA-->B" }); +});