Skip to content

Commit dc3b2c2

Browse files
authored
mermaid support in markdown (#2348)
fix for #2248
1 parent 3b9aab2 commit dc3b2c2

File tree

4 files changed

+869
-5
lines changed

4 files changed

+869
-5
lines changed

frontend/app/element/markdown.tsx

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
resolveSrcSet,
1010
transformBlocks,
1111
} from "@/app/element/markdown-util";
12+
import remarkMermaidToTag from "@/app/element/remark-mermaid-to-tag";
1213
import { boundNumber, useAtomValueSafe } from "@/util/util";
1314
import clsx from "clsx";
1415
import { Atom } from "jotai";
@@ -25,6 +26,18 @@ import { openLink } from "../store/global";
2526
import { IconButton } from "./iconbutton";
2627
import "./markdown.scss";
2728

29+
let mermaidInitialized = false;
30+
let mermaidInstance: any = null;
31+
32+
const initializeMermaid = async () => {
33+
if (!mermaidInitialized) {
34+
const mermaid = await import("mermaid");
35+
mermaidInstance = mermaid.default;
36+
mermaidInstance.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" });
37+
mermaidInitialized = true;
38+
}
39+
};
40+
2841
const Link = ({
2942
setFocusedHeading,
3043
props,
@@ -55,7 +68,65 @@ const Heading = ({ props, hnum }: { props: React.HTMLAttributes<HTMLHeadingEleme
5568
);
5669
};
5770

58-
const Code = ({ className, children }: { className: string; children: React.ReactNode }) => {
71+
const Mermaid = ({ chart }: { chart: string }) => {
72+
const ref = useRef<HTMLDivElement>(null);
73+
const [isLoading, setIsLoading] = useState(true);
74+
const [error, setError] = useState<string | null>(null);
75+
76+
useEffect(() => {
77+
const renderMermaid = async () => {
78+
try {
79+
setIsLoading(true);
80+
setError(null);
81+
82+
await initializeMermaid();
83+
if (!ref.current || !mermaidInstance) {
84+
return;
85+
}
86+
87+
// Normalize the chart text
88+
let normalizedChart = chart
89+
.replace(/<br\s*\/?>/gi, "\n") // Convert <br/> and <br> to newlines
90+
.replace(/\r\n?/g, "\n") // Normalize \r \r\n to \n
91+
.replace(/\n+$/, ""); // Remove final newline
92+
93+
ref.current.removeAttribute("data-processed");
94+
ref.current.textContent = normalizedChart;
95+
// console.log("mermaid", normalizedChart);
96+
await mermaidInstance.run({ nodes: [ref.current] });
97+
setIsLoading(false);
98+
} catch (err) {
99+
console.error("Error rendering mermaid diagram:", err);
100+
setError(`Failed to render diagram: ${err.message || err}`);
101+
setIsLoading(false);
102+
}
103+
};
104+
105+
renderMermaid();
106+
}, [chart]);
107+
108+
useEffect(() => {
109+
if (!ref.current) return;
110+
111+
if (error) {
112+
ref.current.textContent = `Error: ${error}`;
113+
ref.current.className = "mermaid error";
114+
} else if (isLoading) {
115+
ref.current.textContent = "Loading diagram...";
116+
ref.current.className = "mermaid";
117+
} else {
118+
ref.current.className = "mermaid";
119+
}
120+
}, [isLoading, error]);
121+
122+
return <div className="mermaid" ref={ref} />;
123+
};
124+
125+
const Code = ({ className = "", children }: { className?: string; children: React.ReactNode }) => {
126+
if (/\blanguage-mermaid\b/.test(className)) {
127+
const text = Array.isArray(children) ? children.join("") : String(children ?? "");
128+
return <Mermaid chart={text} />;
129+
}
59130
return <code className={className}>{children}</code>;
60131
};
61132

@@ -256,7 +327,7 @@ const Markdown = ({
256327
// Ensure uniqueness of ids between MD preview instances.
257328
const [idPrefix] = useState<string>(crypto.randomUUID());
258329

259-
text = textAtomValue ?? text;
330+
text = textAtomValue ?? text ?? "";
260331
const transformedOutput = transformBlocks(text);
261332
const transformedText = transformedOutput.content;
262333
const contentBlocksMap = transformedOutput.blocks;
@@ -295,6 +366,21 @@ const Markdown = ({
295366
),
296367
};
297368
markdownComponents["waveblock"] = (props: any) => <WaveBlock {...props} blockmap={contentBlocksMap} />;
369+
markdownComponents["mermaidblock"] = (props: any) => {
370+
const getTextContent = (children: any): string => {
371+
if (typeof children === "string") {
372+
return children;
373+
} else if (Array.isArray(children)) {
374+
return children.map(getTextContent).join("");
375+
} else if (children && typeof children === "object" && children.props && children.props.children) {
376+
return getTextContent(children.props.children);
377+
}
378+
return String(children || "");
379+
};
380+
381+
const chartText = getTextContent(props.children);
382+
return <Mermaid chart={chartText} />;
383+
};
298384

299385
const toc = useMemo(() => {
300386
if (showToc && tocRef.current.length > 0) {
@@ -335,12 +421,20 @@ const Markdown = ({
335421
],
336422
waveblock: [["blockkey"]],
337423
},
338-
tagNames: [...(defaultSchema.tagNames || []), "span", "waveblock", "picture", "source"],
424+
tagNames: [
425+
...(defaultSchema.tagNames || []),
426+
"span",
427+
"waveblock",
428+
"picture",
429+
"source",
430+
"mermaidblock",
431+
],
339432
}),
340433
() => rehypeSlug({ prefix: idPrefix }),
341434
];
342435
}
343436
const remarkPlugins: any = [
437+
remarkMermaidToTag,
344438
remarkGfm,
345439
[RemarkFlexibleToc, { tocRef: tocRef.current }],
346440
[createContentBlockPlugin, { blocks: contentBlocksMap }],
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright 2025, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import type { Code, Content, Html, Root } from "mdast";
5+
import type { Plugin } from "unified";
6+
import type { Parent } from "unist";
7+
import { SKIP, visit } from "unist-util-visit";
8+
import type { VFile } from "vfile";
9+
10+
const escapeHTML = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
11+
12+
const remarkMermaidToTag: Plugin<[], Root> = function () {
13+
return (tree: Root, _file: VFile) => {
14+
visit(tree, "code", (node: Code, index: number | null, parent: Parent | null) => {
15+
if (!parent || index === null) return;
16+
if ((node.lang ?? "").toLowerCase() !== "mermaid") return;
17+
18+
const htmlNode: Html = {
19+
type: "html",
20+
value: `<mermaidblock>${escapeHTML(node.value ?? "")}</mermaidblock>`,
21+
};
22+
23+
(parent.children as Content[])[index] = htmlNode as Content;
24+
return SKIP;
25+
});
26+
};
27+
};
28+
29+
export default remarkMermaidToTag;

0 commit comments

Comments
 (0)