From 14aa6120380faa2a472821b12c0ac8b80de70bfc Mon Sep 17 00:00:00 2001 From: Deng Liu Date: Sun, 8 Nov 2026 23:29:11 -0800 Subject: [PATCH] fix(markdown): keep Mermaid styles scoped after rewriting diagram ids (#2288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reid() made cached Mermaid diagrams' ids unique by suffixing every id, but only rewrote references followed by `"` or `)`. The root svg id changed while the selectors in the SVG's own ` + + `` + + `${text.split("\n")[0]}`, }; }); mockIPC((cmd, args) => (cmd === "fs_read_image" ? readImage((args as { path: string }).path) : null)); @@ -236,11 +237,15 @@ describe("mermaid diagrams in a reply", () => { expect(idsOne).toHaveLength(2); expect(idsTwo).toHaveLength(2); expect(idsOne.filter((id) => idsTwo.includes(id))).toEqual([]); - // Every marker reference resolves inside its own copy. + // Every marker reference and scoped `` + + `` + + `` + + `${text.split("\n")[0]}`, }; }), })); @@ -34,6 +36,13 @@ function preview(md: string): HTMLElement { const SEQ = "```mermaid\nsequenceDiagram\n A->B:test\n```\n"; +async function renderedSvg(svg: string): Promise { + m.render.mockResolvedValueOnce({ svg }); + const root = preview(SEQ); + await renderMermaidIn(root).done; + return root.querySelector("svg")!; +} + describe("mermaid in the markdown preview", () => { beforeEach(() => { resetMermaidForTests(); @@ -106,7 +115,7 @@ describe("mermaid in the markdown preview", () => { const edited = preview(SEQ.replace("test", "edited")); const next = renderMermaidIn(edited, previous); expect(next.async).toBe(true); - expect(edited.querySelector(".mermaid-output svg")?.textContent).toBe("sequenceDiagram"); + expect(edited.querySelector(".mermaid-output svg text")?.textContent).toBe("sequenceDiagram"); await next.done; expect(m.render).toHaveBeenCalledTimes(2); ids.add(m.render.mock.calls[1]![0]); @@ -128,17 +137,108 @@ describe("mermaid in the markdown preview", () => { const b = second.querySelector("svg")!; const idsA = idsOf(a); const idsB = idsOf(b); - expect(idsA).toHaveLength(2); - expect(idsB).toHaveLength(2); + expect(idsA).toHaveLength(3); + expect(idsB).toHaveLength(3); expect(idsA.filter((id) => idsB.includes(id))).toEqual([]); - // Every marker reference resolves within its own copy. + // Every marker reference, ``, + ); + expect(svg.querySelector("style")!.textContent).toBe(`#${svg.id}${tail}{fill:none;}`); + }, + ); + + it("rewrites fragment links, quoted URLs and marker ids while preserving suffix selectors", async () => { + const id = "openidea-mermaid-1"; + const marker = `${id}_flowchart-pointEnd`; + const svg = await renderedSvg( + `` + + `` + + `TitleDescription` + + `` + + `` + + `` + + `` + + `` + + `` + + ``, + ); + const markerId = svg.querySelector("marker")!.id; + expect(markerId).toBe(`${svg.id}_flowchart-pointEnd`); + expect(svg.querySelector("[id$='-pointEnd']")).not.toBeNull(); + expect( + [...svg.querySelectorAll("use")].map((el) => el.getAttribute("href") ?? el.getAttribute("xlink:href")), + ).toEqual([`#${markerId}`, `#${markerId}`]); + expect([...svg.querySelectorAll("path[marker-end]")].map((el) => el.getAttribute("marker-end"))).toEqual([ + `url(#${markerId})`, + `url('#${markerId}')`, + `url("#${markerId}")`, + ]); + expect(svg.querySelector("path[style]")!.getAttribute("style")).toBe(`marker-end: url( '#${markerId}' );`); + expect([...svg.querySelectorAll("style")].map((el) => el.textContent)).toEqual([ + `#${svg.id} [id$="-pointEnd"]{fill:#dad;}#${markerId}{stroke:#dad;}`, + `#${svg.id} path{marker-end:url("#${markerId}");}`, + ]); + expect(svg.getAttribute("aria-labelledby")).toBe( + `${svg.querySelector("title")!.id} ${svg.querySelector("desc")!.id}`, + ); + expect(svg.getAttribute("aria-describedby")).toBe(svg.querySelector("desc")!.id); + }); + + it("keeps ids with a shared prefix distinct and leaves unknown references unchanged", async () => { + const short = "openidea-mermaid-1"; + const long = "openidea-mermaid-10"; + const svg = await renderedSvg( + `` + + `` + + `` + + ``, + ); + const longId = svg.querySelector("g")!.id; + expect(longId).toBe(`${svg.id}0`); + expect([...svg.querySelectorAll("use")].map((el) => el.getAttribute("href"))).toEqual([ + `#${svg.id}`, + `#${longId}`, + `#${long}0`, + ]); + expect(svg.querySelector("style")!.textContent).toBe( + `#${svg.id}>g,#${longId}:hover{fill:none;}#${long}0{fill:none;}`, + ); + expect(svg.querySelector("path")!.getAttribute("marker-end")).toBe(`url('#${longId}')`); + }); + + it("leaves ids in ordinary labels and unrelated attributes untouched", async () => { + const id = "openidea-mermaid-1"; + const label = `#${id} ${id} id="${id}" href="#${id}" url(#${id})`; + const svg = await renderedSvg( + `${label}` + + `` + + `Link`, + ); + expect(svg.querySelector("text")!.textContent).toBe(label); + expect(svg.querySelector("g")!.getAttribute("data-id")).toBe(id); + expect(svg.querySelector("g")!.getAttribute("data-label")).toBe(`#${id}`); + expect(svg.querySelector("g")!.getAttribute("title")).toBe(`url(#${id})`); + expect(svg.querySelector("a")!.getAttribute("href")).toBe(`https://example.com/#${id}`); + }); + it("re-renders when switching between built-in palette themes of the same light / dark base", async () => { const root = document.documentElement; const previous = root.dataset.colorTheme; diff --git a/src/features/viewers/markdown/mermaid.ts b/src/features/viewers/markdown/mermaid.ts index 3d7f9ac91..bd9ccbbbd 100644 --- a/src/features/viewers/markdown/mermaid.ts +++ b/src/features/viewers/markdown/mermaid.ts @@ -120,22 +120,57 @@ let applied = 0; * hit reapplies the first render's SVG string. Two copies in one document — the same * diagram twice, or one drawn again while the first sits hidden (a closed details, an * inactive tab) — collide on their marker ids, and Chromium resolves the `url(#…)` - * references against the hidden copy, losing arrowheads. Rewrites every id per - * application: longest first, so an id that extends a shorter one moves out of the way. + * references against the hidden copy, losing arrowheads. + * + * Prefix ids so Mermaid's marker suffix selectors keep matching. Rewrite only + * attributes and CSS rules, leaving diagram labels and CSS colours untouched. */ function reid(svg: string): string { - const ids = [...new Set([...svg.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1]!))]; - if (!ids.length) return svg; - const suffix = `-c${++applied}`; - // References come as `id="…"`, `href="#…"` and `url(#…)`; the lookahead keeps - // an id that extends this one untouched. - for (const id of ids.sort((a, b) => b.length - a.length)) { - svg = svg.replace( - new RegExp(`(\\bid="|#|url\\(#)${escapeRegExp(id)}(?="|\\))`, "g"), - (_m, prefix: string) => `${prefix}${id}${suffix}`, - ); + const tag = /<[a-zA-Z](?:[^>"']|"[^"]*"|'[^']*')*>/g; + const attribute = /(\s)([\w:-]+)(\s*=\s*)(["'])([\s\S]*?)\4/g; + const ids = new Set(); + for (const [markup] of svg.matchAll(tag)) { + for (const attr of markup.matchAll(attribute)) if (attr[2] === "id") ids.add(attr[5]!); } - return svg; + if (!ids.size) return svg; + const prefix = `c${++applied}-`; + const rename = (id: string) => (ids.has(id) ? `${prefix}${id}` : id); + const pattern = [...ids] + .sort((a, b) => b.length - a.length) + .map(escapeRegExp) + .join("|"); + const selector = new RegExp(`#(${pattern})(?=$|[\\s.:>+~\\[,])`, "g"); + const urls = (value: string) => + value.replace( + /(url\(\s*)(["']?)#([^'"\s)]+)(\2\s*\))/g, + (_m, start: string, quote: string, id: string, end: string) => `${start}${quote}#${rename(id)}${end}`, + ); + return svg.replace( + /(]*>)([\s\S]*?)(<\/style\s*>)|<[a-zA-Z](?:[^>"']|"[^"]*"|'[^']*')*>/g, + (markup, open: string | undefined, css: string | undefined, close: string | undefined) => { + if (css !== undefined) { + // Only selector lists contain ID selectors; declarations may contain hex colours. + const scoped = css.replace( + /(^|[{}])([^{}]+)(?=\{)/g, + (_m, boundary: string, selectors: string) => + boundary + selectors.replace(selector, (_m, id: string) => `#${rename(id)}`), + ); + return `${open!}${urls(scoped)}${close!}`; + } + return markup.replace( + attribute, + (_m: string, space: string, name: string, equal: string, quote: string, value: string) => { + if (name === "id") value = rename(value); + else if (name === "href" || name === "xlink:href") { + if (value.startsWith("#")) value = `#${rename(value.slice(1))}`; + } else if (name === "aria-labelledby" || name === "aria-describedby") value = value.replace(/\S+/g, rename); + else if (/^(?:style|fill|stroke|filter|clip-path|mask|marker(?:-start|-mid|-end)?|cursor)$/.test(name)) + value = urls(value); + return `${space}${name}${equal}${quote}${value}${quote}`; + }, + ); + }, + ); } function show(block: HTMLElement, result: Result | null, stale?: Node) {