Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 97 additions & 3 deletions frontend/app/element/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
resolveSrcSet,
transformBlocks,
} from "@/app/element/markdown-util";
import remarkMermaidToTag from "@/app/element/remark-mermaid-to-tag";
import { boundNumber, useAtomValueSafe } from "@/util/util";
import clsx from "clsx";
import { Atom } from "jotai";
Expand All @@ -25,6 +26,18 @@ import { openLink } from "../store/global";
import { IconButton } from "./iconbutton";
import "./markdown.scss";

let mermaidInitialized = false;
let mermaidInstance: any = null;

const initializeMermaid = async () => {
if (!mermaidInitialized) {
const mermaid = await import("mermaid");
mermaidInstance = mermaid.default;
mermaidInstance.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" });
mermaidInitialized = true;
}
};

const Link = ({
setFocusedHeading,
props,
Expand Down Expand Up @@ -55,7 +68,65 @@ const Heading = ({ props, hnum }: { props: React.HTMLAttributes<HTMLHeadingEleme
);
};

const Code = ({ className, children }: { className: string; children: React.ReactNode }) => {
const Mermaid = ({ chart }: { chart: string }) => {
const ref = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const renderMermaid = async () => {
try {
setIsLoading(true);
setError(null);

await initializeMermaid();
if (!ref.current || !mermaidInstance) {
return;
}

// Normalize the chart text
let normalizedChart = chart
.replace(/<br\s*\/?>/gi, "\n") // Convert <br/> and <br> to newlines
.replace(/\r\n?/g, "\n") // Normalize \r \r\n to \n
.replace(/\n+$/, ""); // Remove final newline

ref.current.removeAttribute("data-processed");
ref.current.textContent = normalizedChart;
// console.log("mermaid", normalizedChart);
await mermaidInstance.run({ nodes: [ref.current] });
setIsLoading(false);
} catch (err) {
console.error("Error rendering mermaid diagram:", err);
setError(`Failed to render diagram: ${err.message || err}`);
setIsLoading(false);
}
};

renderMermaid();
}, [chart]);

useEffect(() => {
if (!ref.current) return;

if (error) {
ref.current.textContent = `Error: ${error}`;
ref.current.className = "mermaid error";
} else if (isLoading) {
ref.current.textContent = "Loading diagram...";
ref.current.className = "mermaid";
} else {
ref.current.className = "mermaid";
}
}, [isLoading, error]);

return <div className="mermaid" ref={ref} />;
};

const Code = ({ className = "", children }: { className?: string; children: React.ReactNode }) => {
if (/\blanguage-mermaid\b/.test(className)) {
const text = Array.isArray(children) ? children.join("") : String(children ?? "");
return <Mermaid chart={text} />;
}
return <code className={className}>{children}</code>;
Comment on lines +125 to 130
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix: copy/execute actions return empty text for Mermaid blocks.

When Code returns a Mermaid component, CodeBlock’s getTextContent traverses React children, not DOM textContent set later; result is empty copy/execute payload.

Apply this diff so the original source is still available for copy/execute:

-    if (/\blanguage-mermaid\b/.test(className)) {
-        const text = Array.isArray(children) ? children.join("") : String(children ?? "");
-        return <Mermaid chart={text} />;
-    }
+    if (/\blanguage-mermaid\b/.test(className)) {
+        const text = Array.isArray(children) ? children.join("") : String(children ?? "");
+        return (
+            <>
+                <Mermaid chart={text} />
+                <span style={{ display: "none" }} aria-hidden="true" data-raw={text}>
+                    {text}
+                </span>
+            </>
+        );
+    }

Optionally, also teach getTextContent to read data-raw if present.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const Code = ({ className = "", children }: { className?: string; children: React.ReactNode }) => {
if (/\blanguage-mermaid\b/.test(className)) {
const text = Array.isArray(children) ? children.join("") : String(children ?? "");
return <Mermaid chart={text} />;
}
return <code className={className}>{children}</code>;
const Code = ({ className = "", children }: { className?: string; children: React.ReactNode }) => {
if (/\blanguage-mermaid\b/.test(className)) {
const text = Array.isArray(children) ? children.join("") : String(children ?? "");
return (
<>
<Mermaid chart={text} />
<span style={{ display: "none" }} aria-hidden="true" data-raw={text}>
{text}
</span>
</>
);
}
return <code className={className}>{children}</code>;
}
🤖 Prompt for AI Agents
In frontend/app/element/markdown.tsx around lines 92 to 97, Code currently
returns a Mermaid component with the chart text derived from children, which
makes the original raw source unavailable to CodeBlock.getTextContent; modify
Code so when it detects language-mermaid it preserves the original source by
attaching the raw text to the output (e.g., pass the computed text through a
prop or render a wrapper element with a data-raw attribute containing that text)
so copy/execute can read it instead of traversing children; ensure the
prop/attribute uses the same exact raw string produced now; optionally also
update getTextContent to fall back to reading data-raw when DOM textContent is
empty.

};

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

text = textAtomValue ?? text;
text = textAtomValue ?? text ?? "";
const transformedOutput = transformBlocks(text);
const transformedText = transformedOutput.content;
const contentBlocksMap = transformedOutput.blocks;
Expand Down Expand Up @@ -295,6 +366,21 @@ const Markdown = ({
),
};
markdownComponents["waveblock"] = (props: any) => <WaveBlock {...props} blockmap={contentBlocksMap} />;
markdownComponents["mermaidblock"] = (props: any) => {
const getTextContent = (children: any): string => {
if (typeof children === "string") {
return children;
} else if (Array.isArray(children)) {
return children.map(getTextContent).join("");
} else if (children && typeof children === "object" && children.props && children.props.children) {
return getTextContent(children.props.children);
}
return String(children || "");
};

const chartText = getTextContent(props.children);
return <Mermaid chart={chartText} />;
};

const toc = useMemo(() => {
if (showToc && tocRef.current.length > 0) {
Expand Down Expand Up @@ -335,12 +421,20 @@ const Markdown = ({
],
waveblock: [["blockkey"]],
},
tagNames: [...(defaultSchema.tagNames || []), "span", "waveblock", "picture", "source"],
tagNames: [
...(defaultSchema.tagNames || []),
"span",
"waveblock",
"picture",
"source",
"mermaidblock",
],
}),
() => rehypeSlug({ prefix: idPrefix }),
];
}
const remarkPlugins: any = [
remarkMermaidToTag,
remarkGfm,
[RemarkFlexibleToc, { tocRef: tocRef.current }],
[createContentBlockPlugin, { blocks: contentBlocksMap }],
Expand Down
29 changes: 29 additions & 0 deletions frontend/app/element/remark-mermaid-to-tag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import type { Code, Content, Html, Root } from "mdast";
import type { Plugin } from "unified";
import type { Parent } from "unist";
import { SKIP, visit } from "unist-util-visit";
import type { VFile } from "vfile";

const escapeHTML = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

const remarkMermaidToTag: Plugin<[], Root> = function () {
return (tree: Root, _file: VFile) => {
visit(tree, "code", (node: Code, index: number | null, parent: Parent | null) => {
if (!parent || index === null) return;
if ((node.lang ?? "").toLowerCase() !== "mermaid") return;

const htmlNode: Html = {
type: "html",
value: `<mermaidblock>${escapeHTML(node.value ?? "")}</mermaidblock>`,
};

(parent.children as Content[])[index] = htmlNode as Content;
return SKIP;
});
};
};

export default remarkMermaidToTag;
Loading
Loading