дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
ab5f71b442
коміт
14aa612038
4 files changed
+182
-22
No files matched your search
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Заблокувати користувача