fix(markdown): keep Mermaid styles scoped after rewriting diagram ids (#2288)

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 <style> (`#id{`, `#id .cluster rect`, `#id>`…) did not, so none of Mermaid's rules matched and clusters, nodes and edges rendered solid black; the suffix also broke selectors that match marker ids by suffix. Ids now get a per-application prefix, rewritten consistently in id, href/xlink:href, aria-labelledby/describedby, url(#…) in presentation attributes (quoted or not) and the ID selectors of <style> rules, leaving declarations (hex colours), labels and unknown references untouched; an id that is a prefix of another (`-1` vs `-10`) stays distinct.

Co-authored-by: dengliu <[email protected]>
Цей коміт міститься в:
Deng Liuтаdengliu authored and GitHub committed 2026-11-09 15:29:11 +08:00
1 джерело ab5f71b442
коміт 14aa612038
4 files changed
+182 -22

No files matched your search

+20
Переглянути файл
@@ -340,3 +340,23 @@ test("selects through code blocks and tables while keeping copy controls outside
await copies.last().click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(flow);
});
test("keeps mermaid `<style>` rules scoped to the rendered svg after id rewriting", async ({ page }) => {
const diagram = [
"```mermaid",
"%%{init: {'theme': 'base', 'flowchart': {'htmlLabels': false, 'curve': 'basis'}}}%%",
"flowchart TB",
' subgraph T1["Tier 1"]',
" A[(Local)] <--> B[(Peer)]",
" end",
' subgraph T2["Tier 2"]',
" C[(Host RAM)]",
" end",
' C -->|"PCIe"| A',
"```",
].join("\n");
const reply = await openReply(page, `Diagram:\n\n${diagram}`);
await expect(reply.locator("[data-mermaid-state='ok'] .mermaid-output svg")).toBeVisible();
expect(await css(reply, ".cluster rect", "fill")).not.toBe("rgb(0, 0, 0)");
expect(await css(reply, ".flowchart-link", "fill")).toBe("none");
});
+8 -3
Переглянути файл
@@ -33,8 +33,9 @@ beforeEach(() => {
document.body.append(tmp);
return {
svg:
`<svg id="${id}"><defs><marker id="${id}-arrow" orient="auto"><path d="M0,0L8,4"/></marker></defs>` +
`<path d="M0,4" marker-end="url(#${id}-arrow)"/><text>${text.split("\n")[0]}</text></svg>`,
`<svg id="${id}"><style>#${id} .flowchart-link{fill:none;}#${id} [id$="-arrow"] path{fill:#9fa2a8;}</style>` +
`<defs><marker id="${id}-arrow" orient="auto"><path d="M0,0L8,4"/></marker></defs>` +
`<path class="flowchart-link" d="M0,4" marker-end="url(#${id}-arrow)"/><text>${text.split("\n")[0]}</text></svg>`,
};
});
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 `<style>` selector resolves inside its own copy.
for (const root of [one.container, two.container]) {
const svg = root.querySelector(".mermaid-output svg")!;
const ref = svg.querySelector("[marker-end]")!.getAttribute("marker-end")!;
expect(new Set(idsOf(root)).has(/url\(#(.+)\)/.exec(ref)![1]!)).toBe(true);
expect(svg.querySelector("style")!.textContent).toContain(
`#${svg.getAttribute("id")} .flowchart-link{fill:none;}`,
);
expect(svg.querySelector("defs [id$='-arrow'] path")).not.toBeNull();
}
});
+106 -6
Переглянути файл
@@ -14,8 +14,10 @@ const m = vi.hoisted(() => ({
if (text.includes("bad")) throw new Error("Parse error on line 2:\nbad\n^");
return {
svg:
`<svg id="${id}"><defs><marker id="${id}-arrow" orient="auto"><path d="M0,0L8,4"/></marker></defs>` +
`<path d="M0,4" marker-end="url(#${id}-arrow)"/><text>${text.split("\n")[0]}</text></svg>`,
`<svg id="${id}"><style>#${id}{fill:#dad;}#${id} .cluster rect{fill:#2e2f30;}#${id} .flowchart-link{fill:none;}#${id} [id$="-arrow"] path{fill:#9fa2a8;}</style>` +
`<defs><marker id="${id}-arrow" orient="auto"><path d="M0,0L8,4"/></marker></defs>` +
`<g class="cluster" id="dad"><rect/></g>` +
`<path class="flowchart-link" d="M0,4" marker-end="url(#${id}-arrow)"/><text>${text.split("\n")[0]}</text></svg>`,
};
}),
}));
@@ -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<SVGSVGElement> {
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, `<style>#<svg-id>` rule and `[id$="-arrow"]` suffix rule
// resolves within its own copy without touching hex colours (`#dad`) in `<style>`.
for (const svg of [a, b]) {
const own = new Set(idsOf(svg));
const rootId = svg.getAttribute("id")!;
const ref = svg.querySelector("[marker-end]")!.getAttribute("marker-end")!;
expect(own.has(/url\(#(.+)\)/.exec(ref)![1]!)).toBe(true);
const css = svg.querySelector("style")!.textContent!;
expect(css).toContain(`#${rootId}{fill:#dad;}`);
expect(css).toContain(`#${rootId} .cluster rect{fill:#2e2f30;}`);
expect(css).toContain(`#${rootId} .flowchart-link{fill:none;}`);
expect(svg.querySelector("defs [id$='-arrow'] path")).not.toBeNull();
}
});
it.each(["", ".node", ":hover", ">g", "+g", "~g", "[role]", ", rect", " .cluster rect", "\nrect"])(
"rewrites style selectors followed by %j",
async (tail) => {
const svg = await renderedSvg(
`<svg id="openidea-mermaid-1"><style>#openidea-mermaid-1${tail}{fill:none;}</style></svg>`,
);
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(
`<svg id="${id}" aria-labelledby="title desc" aria-describedby="desc">` +
`<style>#${id} [id$="-pointEnd"]{fill:#dad;}#${marker}{stroke:#dad;}</style>` +
`<title id="title">Title</title><desc id="desc">Description</desc>` +
`<marker id="${marker}"/><g id="dad"/>` +
`<use href="#${marker}"/><use xlink:href="#${marker}"/>` +
`<path marker-end="url(#${marker})"/>` +
`<path marker-end="url('#${marker}')"/>` +
`<path marker-end='url("#${marker}")'/>` +
`<path style="marker-end: url( '#${marker}' );"/>` +
`<style>#${id} path{marker-end:url("#${marker}");}</style></svg>`,
);
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(
`<svg id="${short}"><g id="${long}"/>` +
`<style>#${short}>g,#${long}:hover{fill:none;}#${long}0{fill:none;}</style>` +
`<use href="#${short}"/><use href="#${long}"/><use href="#${long}0"/>` +
`<path marker-end="url('#${long}')"/></svg>`,
);
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(
`<svg id="${id}"><text>${label}</text>` +
`<g data-id="${id}" data-label="#${id}" title="url(#${id})"/>` +
`<a href="https://example.com/#${id}">Link</a></svg>`,
);
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;
+48 -13
Переглянути файл
@@ -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<string>();
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(
/(<style\b[^>]*>)([\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) {