Skip to content
Open
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
Binary file added .github/images/mermaid-web-render.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"js-md5": "^0.8.3",
"js-yaml": "^4.1.1",
"lucide-react": "^0.561.0",
"mermaid": "^11.12.2",
"motion": "^12.23.24",
"nanoid": "^5.1.6",
"radix-ui": "^1.4.3",
Expand Down
71 changes: 41 additions & 30 deletions web/src/components/ai-elements/code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MermaidDiagram } from "./mermaid-diagram";
import {
type ComponentProps,
createContext,
Expand Down Expand Up @@ -70,6 +71,8 @@ const DOWNLOAD_EXTENSION_BY_LANGUAGE: Record<string, string> = {
yml: "yml",
markdown: "md",
md: "md",
mermaid: "mmd",
mmd: "mmd",
python: "py",
py: "py",
go: "go",
Expand Down Expand Up @@ -306,25 +309,27 @@ export const CodeBlock = ({
hadLineNumbers,
numbers,
} = useMemo(() => sanitizeCodeForLineNumbers(code ?? ""), [code]);
const normalizedLanguage = language?.toLowerCase();
const isMermaid = normalizedLanguage === "mermaid";
const copyText = sanitizedCode;
const wantLineNumbers = showLineNumbers || hadLineNumbers;
const cacheKey = useMemo(() => {
if (!language) {
if (!normalizedLanguage || isMermaid) {
return null;
}
return getHighlightCacheKey(
sanitizedCode,
language,
normalizedLanguage,
wantLineNumbers,
numbers,
);
}, [sanitizedCode, language, wantLineNumbers, numbers]);
}, [isMermaid, normalizedLanguage, sanitizedCode, wantLineNumbers, numbers]);

useEffect(() => {
let cancelled = false;
setHtml("");
setDarkHtml("");
if (!language || !cacheKey) {
if (!normalizedLanguage || !cacheKey) {
return () => {
cancelled = true;
};
Expand All @@ -337,7 +342,7 @@ export const CodeBlock = ({
cancelled = true;
};
}
highlightCode(sanitizedCode, language, wantLineNumbers, numbers).then(
highlightCode(sanitizedCode, normalizedLanguage, wantLineNumbers, numbers).then(
(highlighted) => {
if (cancelled || !highlighted) {
return;
Expand All @@ -351,7 +356,7 @@ export const CodeBlock = ({
return () => {
cancelled = true;
};
}, [cacheKey, language, numbers, sanitizedCode, wantLineNumbers]);
}, [cacheKey, normalizedLanguage, numbers, sanitizedCode, wantLineNumbers]);

// Keep fallback layout close to highlighted output to minimize height deltas.
const contentClassName = [
Expand Down Expand Up @@ -435,31 +440,37 @@ export const CodeBlock = ({
)}
>
<div className="relative">
{html ? (
<div
className={cn("dark:hidden", contentClassName)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: html }}
/>
{isMermaid ? (
<MermaidDiagram code={copyText} />
) : (
<div className={cn("dark:hidden", contentClassName)}>
<pre>
<code>{copyText}</code>
</pre>
</div>
)}
{darkHtml ? (
<div
className={cn("hidden dark:block", contentClassName)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: darkHtml }}
/>
) : (
<div className={cn("hidden dark:block", contentClassName)}>
<pre>
<code>{copyText}</code>
</pre>
</div>
<>
{html ? (
<div
className={cn("dark:hidden", contentClassName)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<div className={cn("dark:hidden", contentClassName)}>
<pre>
<code>{copyText}</code>
</pre>
</div>
)}
{darkHtml ? (
<div
className={cn("hidden dark:block", contentClassName)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: darkHtml }}
/>
) : (
<div className={cn("hidden dark:block", contentClassName)}>
<pre>
<code>{copyText}</code>
</pre>
</div>
)}
</>
)}
</div>
</div>
Expand Down
193 changes: 193 additions & 0 deletions web/src/components/ai-elements/mermaid-diagram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"use client";

import type { Theme } from "@/hooks/use-theme";
import { cn } from "@/lib/utils";
import { AlertTriangleIcon, Loader2Icon } from "lucide-react";
import { type HTMLAttributes, useEffect, useId, useRef, useState } from "react";

type MermaidDiagramProps = HTMLAttributes<HTMLDivElement> & {
code: string;
};

type MermaidModule = typeof import("mermaid");
type MermaidBindFunctions = (element: Element) => void;

let mermaidModulePromise: Promise<MermaidModule> | null = null;
let mermaidRenderNonce = 0;

const loadMermaidModule = async (): Promise<MermaidModule> => {
if (!mermaidModulePromise) {
mermaidModulePromise = import("mermaid");
}
return mermaidModulePromise;
};

const getErrorMessage = (error: unknown): string => {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return "Unable to render Mermaid diagram. Showing the source below.";
};

const getDocumentTheme = (): Theme => {
if (typeof document === "undefined") {
return "light";
}
return document.documentElement.classList.contains("dark") ? "dark" : "light";
};

const useDocumentTheme = (): Theme => {
const [theme, setTheme] = useState<Theme>(() => getDocumentTheme());

useEffect(() => {
if (typeof document === "undefined") {
return;
}

const root = document.documentElement;
const updateTheme = () => {
setTheme(root.classList.contains("dark") ? "dark" : "light");
};

updateTheme();

const observer = new MutationObserver(updateTheme);
observer.observe(root, {
attributes: true,
attributeFilter: ["class"],
});

return () => observer.disconnect();
}, []);

return theme;
};

export function MermaidDiagram({
code,
className,
...props
}: MermaidDiagramProps) {
const theme = useDocumentTheme();
const containerRef = useRef<HTMLDivElement>(null);
const bindFunctionsRef = useRef<MermaidBindFunctions | null>(null);
const renderId = useId().replaceAll(":", "");
const [svg, setSvg] = useState("");
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
const source = code.trim();

setSvg("");
setError(null);
bindFunctionsRef.current = null;

if (!source) {
setError("Mermaid diagram is empty.");
return () => {
cancelled = true;
};
}

const renderDiagram = async () => {
try {
const mermaid = (await loadMermaidModule()).default;

mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
theme: theme === "dark" ? "dark" : "default",
fontFamily:
"Inter Variable, Inter, -apple-system, BlinkMacSystemFont, sans-serif",
});

const diagramId = `mermaid-${renderId}-${mermaidRenderNonce++}`;
const rendered = await mermaid.render(diagramId, source);

if (cancelled) {
return;
}

bindFunctionsRef.current = rendered.bindFunctions ?? null;
setSvg(rendered.svg);
setError(null);
} catch (renderError) {
if (cancelled) {
return;
}

bindFunctionsRef.current = null;
setSvg("");
setError(getErrorMessage(renderError));
}
};

void renderDiagram();

return () => {
cancelled = true;
};
}, [code, renderId, theme]);

useEffect(() => {
if (!svg || !bindFunctionsRef.current || !containerRef.current) {
return;
}

bindFunctionsRef.current(containerRef.current);
bindFunctionsRef.current = null;
}, [svg]);

if (error) {
return (
<div
className={cn(
"rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm",
className,
)}
{...props}
>
<div className="mb-3 flex items-start gap-2 text-destructive">
<AlertTriangleIcon className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0">
<div className="font-medium">Couldn't render Mermaid diagram</div>
<div className="mt-1 whitespace-pre-wrap break-words text-xs text-destructive/90">
{error}
</div>
</div>
</div>
<pre className="overflow-x-auto rounded bg-card p-3 text-xs text-foreground">
<code>{code}</code>
</pre>
</div>
);
}

if (!svg) {
return (
<div
className={cn(
"flex min-h-32 items-center justify-center rounded-md border border-dashed border-border/80 bg-muted/20 px-4 py-10 text-sm text-muted-foreground",
className,
)}
{...props}
>
<Loader2Icon className="mr-2 size-4 animate-spin" />
Rendering Mermaid diagram...
</div>
);
}

return (
<div
ref={containerRef}
className={cn("px-3 py-4", className)}
data-mermaid-diagram=""
// biome-ignore lint/security/noDangerouslySetInnerHtml: Mermaid renders trusted SVG from local code blocks with strict security mode.
dangerouslySetInnerHTML={{ __html: svg }}
{...props}
/>
);
}
15 changes: 15 additions & 0 deletions web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,21 @@ body {
font-size: 0.75rem;
}

[data-mermaid-diagram] {
overflow-x: auto;
}

[data-mermaid-diagram] svg {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
}

[data-mermaid-diagram] .label {
color: var(--foreground);
}

/* ===== Animations ===== */
@keyframes glow-pulse {
0%,
Expand Down
Loading