Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"jszip": "3.10.1",
"lexical": "^0.41.0",
"lucide-react": "^0.564.0",
"mermaid": "^11.16.1",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-markdown": "^10.1.0",
Expand Down
36 changes: 25 additions & 11 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ import {
BrowserPreviewUnavailableError,
} from "../browser/openFileInPreview";
import { resolveLinkTarget } from "../browser/browserLinkTarget";
import { MermaidDiagram } from "./MermaidDiagram";

interface ChatMarkdownProps {
text: string;
Expand Down Expand Up @@ -897,7 +898,7 @@ function MarkdownCodeBlock({
type="button"
variant="ghost"
size="icon-xs"
className="chat-markdown-chrome-action"
className="chat-markdown-chrome-action chat-markdown-wrap-action"
aria-pressed={wrapped}
onClick={() => setWrapped((value) => !value)}
aria-label={wrapLabel}
Expand Down Expand Up @@ -2713,23 +2714,36 @@ function ChatMarkdown({

const language = extractFenceLanguage(codeBlock.className);
const fenceTitle = extractFenceTitle(extractPreCodeMeta(node));
const codeFallback = (
<RenderErrorBoundary fallback={<pre {...props}>{children}</pre>}>
<Suspense fallback={<pre {...props}>{children}</pre>}>
<SuspenseShikiCodeBlock
className={codeBlock.className}
code={codeBlock.code}
themeName={diffThemeName}
isStreaming={isStreaming}
/>
</Suspense>
</RenderErrorBoundary>
);
const renderMermaid = !isStreaming && language.toLowerCase() === "mermaid";
return (
<MarkdownCodeBlock
code={codeBlock.code}
language={language}
fenceTitle={fenceTitle}
theme={resolvedTheme}
>
<RenderErrorBoundary fallback={<pre {...props}>{children}</pre>}>
<Suspense fallback={<pre {...props}>{children}</pre>}>
<SuspenseShikiCodeBlock
className={codeBlock.className}
code={codeBlock.code}
themeName={diffThemeName}
isStreaming={isStreaming}
/>
</Suspense>
</RenderErrorBoundary>
{renderMermaid ? (
<MermaidDiagram
key={codeBlock.code}
code={codeBlock.code}
theme={resolvedTheme}
fallback={codeFallback}
/>
) : (
codeFallback
)}
</MarkdownCodeBlock>
);
},
Expand Down
79 changes: 79 additions & 0 deletions apps/web/src/components/MermaidDiagram.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";

const mermaid = vi.hoisted(() => ({
initialize: vi.fn(),
render: vi.fn(),
}));

vi.mock("mermaid", () => ({ default: mermaid }));

import { renderMermaidDiagram } from "./MermaidDiagram";
import { serializeMarkdownCodeFence } from "../markdown-clipboard";

describe("renderMermaidDiagram", () => {
beforeEach(() => {
mermaid.initialize.mockReset();
mermaid.render.mockReset();
});

it("renders with strict security and the selected theme", async () => {
mermaid.render.mockResolvedValue({ svg: "<svg />" });

await renderMermaidDiagram("diagram-1", "flowchart LR\nA-->B", "dark");

expect(mermaid.initialize).toHaveBeenCalledWith({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
secure: [
"secure",
"securityLevel",
"startOnLoad",
"maxTextSize",
"suppressErrorRendering",
"maxEdges",
"themeCSS",
"fontFamily",
"altFontFamily",
],
theme: "dark",
});
expect(mermaid.render).toHaveBeenCalledWith("diagram-1", "flowchart LR\nA-->B");
});

it("continues rendering after an invalid diagram", async () => {
mermaid.render
.mockRejectedValueOnce(new Error("Invalid diagram"))
.mockResolvedValueOnce({ svg: "<svg />" });

await expect(renderMermaidDiagram("diagram-1", "invalid", "light")).rejects.toThrow();
await expect(
renderMermaidDiagram("diagram-2", "sequenceDiagram\nA->>B: Hi", "light"),
).resolves.toEqual({ svg: "<svg />" });
});

it("skips queued work after its diagram unmounts", async () => {
let finishFirstRender!: (result: { svg: string }) => void;
mermaid.render.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishFirstRender = resolve;
}),
);

const first = renderMermaidDiagram("diagram-1", "flowchart LR\nA-->B", "light");
await vi.waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(1));
const second = renderMermaidDiagram("diagram-2", "flowchart LR\nB-->C", "light", () => false);
finishFirstRender({ svg: "<svg />" });

await first;
await expect(second).resolves.toBeNull();
expect(mermaid.render).toHaveBeenCalledTimes(1);
});

it("chooses a fence longer than backtick runs in copied source", () => {
expect(serializeMarkdownCodeFence("flowchart LR\n%% ``` in a comment", "mermaid")).toBe(
"````mermaid\nflowchart LR\n%% ``` in a comment\n````\n\n",
);
});
});
105 changes: 105 additions & 0 deletions apps/web/src/components/MermaidDiagram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import type { RenderResult } from "mermaid";

import { serializeMarkdownCodeFence } from "../markdown-clipboard";

type MermaidTheme = "light" | "dark";

// Mermaid configuration is global, so initialization and rendering must stay paired.
let mermaidRenderQueue = Promise.resolve();

export function renderMermaidDiagram(
id: string,
code: string,
theme: MermaidTheme,
isActive: () => boolean = () => true,
) {
const render = async () => {
if (!isActive()) return null;
const { default: mermaid } = await import("mermaid");
if (!isActive()) return null;
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
secure: [
"secure",
"securityLevel",
"startOnLoad",
"maxTextSize",
"suppressErrorRendering",
"maxEdges",
"themeCSS",
"fontFamily",
"altFontFamily",
],
theme: theme === "dark" ? "dark" : "default",
});
return mermaid.render(id, code);
};

const result = mermaidRenderQueue.then(render, render);
mermaidRenderQueue = result.then(
() => undefined,
() => undefined,
);
return result;
}

export function MermaidDiagram({
code,
theme,
fallback,
}: {
code: string;
theme: MermaidTheme;
fallback: ReactNode;
}) {
const reactId = useId();
const diagramId = `t3-mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const renderSequenceRef = useRef(0);
const diagramRef = useRef<HTMLDivElement>(null);
const [renderedDiagram, setRenderedDiagram] = useState<{
code: string;
theme: MermaidTheme;
result: RenderResult;
} | null>(null);

useEffect(() => {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
let active = true;
const renderId = `${diagramId}-${renderSequenceRef.current++}`;
void renderMermaidDiagram(renderId, code, theme, () => active).then(
(nextResult) => {
if (active && nextResult) setRenderedDiagram({ code, theme, result: nextResult });
},
() => undefined,
);
return () => {
active = false;
};
}, [code, diagramId, theme]);

useLayoutEffect(() => {
const svg = diagramRef.current?.querySelector("svg");
const width = svg?.viewBox.baseVal.width ?? 0;
if (svg && Number.isFinite(width) && width > 0) {
svg.style.width = `${Math.ceil(width)}px`;
svg.style.maxWidth = "none";
}
});

const result =
renderedDiagram?.code === code && renderedDiagram.theme === theme
? renderedDiagram.result
: null;
if (!result) return fallback;

return (
<div
ref={diagramRef}
className="chat-markdown-mermaid"
data-markdown-copy={serializeMarkdownCodeFence(code, "mermaid")}
Comment thread
adamblumoff marked this conversation as resolved.
dangerouslySetInnerHTML={{ __html: result.svg }}
/>
Comment thread
adamblumoff marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
);
}
17 changes: 17 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1814,6 +1814,23 @@ code {
background: transparent !important;
}

.chat-markdown .chat-markdown-mermaid {
max-width: 100%;
overflow-x: auto;
padding: 1rem;
}

.chat-markdown .chat-markdown-codeblock:has(.chat-markdown-mermaid) .chat-markdown-wrap-action {
display: none;
}

.chat-markdown .chat-markdown-mermaid svg {
display: block;
max-width: none;
height: auto;
margin: 0 auto;
}

/* Diagnostics-style tables: row separators only, uppercase headers, and a
scroll-fade container for horizontal overflow. The root chat-markdown
wrapping rules (overflow-wrap: anywhere) would let columns shrink to single
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/markdown-clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ function codeFenceFor(code: string): string {
return "`".repeat(Math.max(3, longestRun + 1));
}

export function serializeMarkdownCodeFence(code: string, infoString: string): string {
const normalizedCode = code.replace(/\n$/, "");
const fence = codeFenceFor(normalizedCode);
return `${fence}${infoString}\n${normalizedCode}\n${fence}\n\n`;
}

function resolveCodeBlockLanguage(pre: Element): string | null {
const declared =
pre.closest("[data-language]")?.getAttribute("data-language") ??
Expand All @@ -80,9 +86,7 @@ function resolveCodeBlockLanguage(pre: Element): string | null {
}

function serializeCodeBlock(pre: Element): string {
const code = (pre.textContent ?? "").replace(/\n$/, "");
const fence = codeFenceFor(code);
return `${fence}${resolveCodeBlockLanguage(pre) ?? ""}\n${code}\n${fence}\n\n`;
return serializeMarkdownCodeFence(pre.textContent ?? "", resolveCodeBlockLanguage(pre) ?? "");
}

function serializeTableCell(cell: Element): string {
Expand Down
Loading
Loading