fix: show sandbox flowcharts on Win7 Chrome and repair common Mermaid syntax
Disable HTML labels so nodes use native SVG text, and rewrite bare edge labels plus invalid class statements before render. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
31cfa93408
commit
7c15ca6710
6 files changed
+196
-5
No files matched your search
@@ -31,6 +31,11 @@ const requiredLegacyFallbacks = [
|
||||
pattern: /<InputGroup className="h-auto min-h-16">/,
|
||||
description: "Prompt textarea must not rely on :has() for its parent height",
|
||||
},
|
||||
{
|
||||
file: "src/core/utils/mermaid.ts",
|
||||
pattern: /htmlLabels:\s*false[\s\S]*secure:\s*\[[\s\S]*"htmlLabels"/,
|
||||
description: "Sandbox flowcharts must use native SVG labels in legacy browsers",
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of cssFiles) {
|
||||
|
||||
@@ -143,6 +143,7 @@ export function MarkdownContent({
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -5,22 +5,27 @@ import remarkMath from "remark-math";
|
||||
import type { StreamdownProps } from "streamdown";
|
||||
|
||||
import { rehypeSplitWordsIntoSpans } from "../rehype";
|
||||
import { SANDBOX_MERMAID_CONFIG } from "../utils/mermaid";
|
||||
import { remarkSanitizeMermaid } from "./remark-sanitize-mermaid";
|
||||
|
||||
export const streamdownPlugins = {
|
||||
remarkPlugins: [
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: true }],
|
||||
remarkSanitizeMermaid,
|
||||
] as StreamdownProps["remarkPlugins"],
|
||||
rehypePlugins: [
|
||||
rehypeRaw,
|
||||
[rehypeKatex, { output: "html" }],
|
||||
] as StreamdownProps["rehypePlugins"],
|
||||
mermaidConfig: SANDBOX_MERMAID_CONFIG,
|
||||
};
|
||||
|
||||
export const streamdownPluginsWithWordAnimation = {
|
||||
remarkPlugins: [
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: true }],
|
||||
remarkSanitizeMermaid,
|
||||
] as StreamdownProps["remarkPlugins"],
|
||||
rehypePlugins: [
|
||||
[rehypeKatex, { output: "html" }],
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { visit } from "unist-util-visit";
|
||||
|
||||
import { sanitizeMermaidSource } from "../utils/mermaid";
|
||||
|
||||
type MermaidCodeNode = {
|
||||
lang?: string | null;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function remarkSanitizeMermaid() {
|
||||
return (tree: unknown) => {
|
||||
visit(tree as never, "code", (node: MermaidCodeNode) => {
|
||||
const lang = (node.lang ?? "").toLowerCase();
|
||||
if (lang !== "mermaid" && lang !== "mmd") return;
|
||||
node.value = sanitizeMermaidSource(node.value);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { sanitizeMermaidSource } from "./mermaid";
|
||||
|
||||
test("inserts a missing arrow before a bare edge label", () => {
|
||||
const input = "E --> G|价值输出 V_s| H[PPO 损失]";
|
||||
assert.equal(
|
||||
sanitizeMermaidSource(input),
|
||||
"E --> G -->|价值输出 V_s| H[PPO 损失]",
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves already-valid labeled edges unchanged", () => {
|
||||
const input = "E -->|价值输出 V_s| H[PPO 损失]\nG -->|状态| I";
|
||||
assert.equal(sanitizeMermaidSource(input), input);
|
||||
});
|
||||
|
||||
test("fixes a label attached to a shaped node", () => {
|
||||
const input = "E[Actor 网络]|价值输出 V_s| H[PPO 损失]";
|
||||
assert.equal(
|
||||
sanitizeMermaidSource(input),
|
||||
"E[Actor 网络] -->|价值输出 V_s| H[PPO 损失]",
|
||||
);
|
||||
});
|
||||
|
||||
test("fixes several bare labels on one line", () => {
|
||||
const input = "A|x| B|y| C";
|
||||
assert.equal(sanitizeMermaidSource(input), "A -->|x| B -->|y| C");
|
||||
});
|
||||
|
||||
test("inserts a space after class when the model starts the id list with a comma", () => {
|
||||
const input = " class,E,F,G,H,I,J,K loss;";
|
||||
assert.equal(
|
||||
sanitizeMermaidSource(input),
|
||||
" class E,F,G,H,I,J,K loss;",
|
||||
);
|
||||
});
|
||||
|
||||
test("splits zip-assigned class names onto one statement per class", () => {
|
||||
const input = " class A,B,C,D input,module,module,module;";
|
||||
assert.equal(
|
||||
sanitizeMermaidSource(input),
|
||||
" class A input;\n class B,C,D module;",
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves a valid class assignment and classDef alone", () => {
|
||||
const input = [
|
||||
" classDef input fill:#e3f2fd,stroke:#1565c0;",
|
||||
" class A,B input;",
|
||||
].join("\n");
|
||||
assert.equal(sanitizeMermaidSource(input), input);
|
||||
});
|
||||
|
||||
test("repairs the DAC-PPO architecture diagram class block", () => {
|
||||
const input = `graph TD
|
||||
A[智能体观测 o_t] --> B[个体 Encoder]
|
||||
E --> G -->|价值输出 V_s| H[PPO 损失]
|
||||
classDef input fill:#e3f2fd,stroke:#1565c0;
|
||||
classDef module fill:#fff3e0,stroke:#e65100;
|
||||
classDef loss fill:#e8f5e9,stroke:#2e7d32;
|
||||
class A,B,C,D input,module,module,module;
|
||||
class,E,F,G,H,I,J,K loss;`;
|
||||
const output = sanitizeMermaidSource(input);
|
||||
assert.match(output, /class A input;/);
|
||||
assert.match(output, /class B,C,D module;/);
|
||||
assert.match(output, /class E,F,G,H,I,J,K loss;/);
|
||||
assert.doesNotMatch(output, /class,/);
|
||||
assert.doesNotMatch(output, /input,module/);
|
||||
});
|
||||
@@ -2,6 +2,100 @@ import { getFileName } from "./files";
|
||||
|
||||
let mermaidInitialized = false;
|
||||
|
||||
/**
|
||||
* Keep sandbox diagrams on the SVG-only Mermaid rendering path.
|
||||
*
|
||||
* Mermaid's default HTML labels are implemented with SVG `foreignObject`.
|
||||
* Although that looks a little richer in current browsers, many Windows 7
|
||||
* Chromium shells either do not support it or calculate its dimensions
|
||||
* incorrectly, leaving nodes blank or overlapping. Native SVG text works in
|
||||
* the Chrome 100 baseline used by this application and in older Win7 browser
|
||||
* shells as well.
|
||||
*
|
||||
* `htmlLabels` is marked secure so a diagram's front matter/directive cannot
|
||||
* switch this preview back to `foreignObject` rendering.
|
||||
*/
|
||||
export const SANDBOX_MERMAID_CONFIG = {
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict" as const,
|
||||
htmlLabels: false,
|
||||
fontFamily: '"Microsoft YaHei", "Microsoft YaHei UI", SimSun, Arial, sans-serif',
|
||||
flowchart: {
|
||||
// A simple path is more reliable than the spline renderer in older
|
||||
// Chromium SVG implementations and is easier to read at small sizes.
|
||||
curve: "linear" as const,
|
||||
useMaxWidth: false,
|
||||
},
|
||||
secure: [
|
||||
"secure",
|
||||
"securityLevel",
|
||||
"startOnLoad",
|
||||
"maxTextSize",
|
||||
"suppressErrorRendering",
|
||||
"maxEdges",
|
||||
"htmlLabels",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Models often emit almost-Mermaid that the parser rejects:
|
||||
* - `A --> B|label| C` instead of `A --> B -->|label| C`
|
||||
* - `class,E,F loss;` (comma after `class`) instead of `class E,F loss;`
|
||||
* - `class A,B input,module;` (one class name per node) instead of
|
||||
* separate `class` statements
|
||||
*
|
||||
* Already-valid `-->|label|` links and single-class assignments are kept.
|
||||
*/
|
||||
export function sanitizeMermaidSource(source: string): string {
|
||||
return sanitizeMermaidClassStatements(
|
||||
source
|
||||
.replace(
|
||||
/(^|[\s;])([A-Za-z][\w-]*|[\u4e00-\u9fff]+)\|([^|\n]+)\|/gm,
|
||||
"$1$2 -->|$3|",
|
||||
)
|
||||
.replace(/(\])\|([^|\n]+)\|/g, "$1 -->|$2|"),
|
||||
);
|
||||
}
|
||||
|
||||
const CLASS_NODE_LIST = /[A-Za-z][\w-]*(?:\s*,\s*[A-Za-z][\w-]*)*/;
|
||||
const CLASS_NAME_LIST = /[A-Za-z][\w-]*(?:\s*,\s*[A-Za-z][\w-]*)*/;
|
||||
|
||||
function sanitizeMermaidClassStatements(source: string): string {
|
||||
return source.replace(
|
||||
/^([ \t]*)class(?!Def\b)[ \t]*,?[ \t]*(\S[^\n]*)$/gm,
|
||||
(full, indent: string, rest: string) => {
|
||||
const trimmed = rest.replace(/;+\s*$/, "").trim();
|
||||
const matched = trimmed.match(
|
||||
new RegExp(`^(${CLASS_NODE_LIST.source})\\s+(${CLASS_NAME_LIST.source})$`),
|
||||
);
|
||||
if (!matched) {
|
||||
return full;
|
||||
}
|
||||
const nodeIds = matched[1].split(/\s*,\s*/).filter(Boolean);
|
||||
const classNames = matched[2].split(/\s*,\s*/).filter(Boolean);
|
||||
if (nodeIds.length === 0 || classNames.length === 0) {
|
||||
return full;
|
||||
}
|
||||
if (classNames.length === 1) {
|
||||
return `${indent}class ${nodeIds.join(",")} ${classNames[0]};`;
|
||||
}
|
||||
const groups: Array<{ name: string; ids: string[] }> = [];
|
||||
for (let index = 0; index < nodeIds.length; index += 1) {
|
||||
const name = classNames[index] ?? classNames[classNames.length - 1];
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.name === name) {
|
||||
last.ids.push(nodeIds[index]);
|
||||
} else {
|
||||
groups.push({ name, ids: [nodeIds[index]] });
|
||||
}
|
||||
}
|
||||
return groups
|
||||
.map((group) => `${indent}class ${group.ids.join(",")} ${group.name};`)
|
||||
.join("\n");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function isMermaidFile(filepath: string): boolean {
|
||||
return /\.mmd$/i.test(filepath);
|
||||
}
|
||||
@@ -14,17 +108,14 @@ async function getMermaidModule() {
|
||||
const mod = await import("mermaid");
|
||||
const mermaid = mod.default;
|
||||
if (!mermaidInitialized) {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
});
|
||||
mermaid.initialize(SANDBOX_MERMAID_CONFIG);
|
||||
mermaidInitialized = true;
|
||||
}
|
||||
return mermaid;
|
||||
}
|
||||
|
||||
export async function renderMermaidToSvg(source: string): Promise<string> {
|
||||
const text = source.trim();
|
||||
const text = sanitizeMermaidSource(source).trim();
|
||||
if (!text) {
|
||||
throw new Error("Mermaid 内容为空,无法生成图片");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user