Skip to content

Commit 6c39ff9

Browse files
authored
Render mermaid (#170)
1 parent 08469f8 commit 6c39ff9

File tree

6 files changed

+1118
-0
lines changed

6 files changed

+1118
-0
lines changed

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"import-in-the-middle": "^1.14.2",
4848
"jsonwebtoken": "^9.0.2",
4949
"lucide-react": "^0.509.0",
50+
"mermaid": "^11.7.0",
5051
"next": "^15.3.3",
5152
"next-intl": "^4.1.0",
5253
"next-themes": "^0.4.6",
@@ -79,6 +80,7 @@
7980
"@testing-library/jest-dom": "^6.6.3",
8081
"@testing-library/react": "^16.3.0",
8182
"@testing-library/user-event": "^14.6.1",
83+
"@types/hast": "^3.0.4",
8284
"@types/node": "^22.15.27",
8385
"@types/pg": "^8.15.2",
8486
"@types/react": "^19.1.6",

apps/web/src/app/(authenticated)/usage/Messages.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Message } from '@/actions/analytics';
55
import { cn } from '@/lib/utils';
66
import { formatTimestamp } from '@/lib/formatters';
77
import { useAutoScroll } from '@/hooks/useAutoScroll';
8+
import { CodeBlock } from '@/components/ui/CodeBlock';
89

910
// Custom component to render links as plain text to avoid broken/nonsensical links
1011
const PlainTextLink = ({ children }: { children?: React.ReactNode }) => {
@@ -146,6 +147,7 @@ export const Messages = ({ messages }: MessagesProps) => {
146147
<ReactMarkdown
147148
components={{
148149
a: PlainTextLink,
150+
code: CodeBlock,
149151
}}
150152
>
151153
{message.text}

apps/web/src/app/globals.css

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,4 +274,103 @@
274274
@apply italic;
275275
}
276276
}
277+
278+
.mermaid-diagram {
279+
& svg {
280+
@apply max-w-full h-auto;
281+
pointer-events: none;
282+
}
283+
284+
/* Allow pointer events on interactive elements within the SVG */
285+
& svg a,
286+
& svg button,
287+
& svg [role='button'] {
288+
pointer-events: auto;
289+
}
290+
291+
/* Ensure mermaid diagrams respect theme colors */
292+
& .node rect,
293+
& .node circle,
294+
& .node ellipse,
295+
& .node polygon {
296+
@apply fill-background stroke-border;
297+
}
298+
299+
& .node .label {
300+
@apply fill-foreground;
301+
}
302+
303+
& .edgePath .path {
304+
@apply stroke-foreground;
305+
}
306+
307+
& .edgeLabel {
308+
@apply fill-background;
309+
}
310+
311+
& .cluster rect {
312+
@apply fill-muted stroke-border;
313+
}
314+
315+
& .titleText {
316+
@apply fill-foreground;
317+
}
318+
319+
/* Sequence diagram specific styles */
320+
& .actor {
321+
@apply fill-background stroke-border;
322+
}
323+
324+
& .actor-line {
325+
@apply stroke-border;
326+
}
327+
328+
& .messageLine0,
329+
& .messageLine1 {
330+
@apply stroke-foreground;
331+
}
332+
333+
& .messageText {
334+
@apply fill-foreground;
335+
}
336+
337+
& .loopText {
338+
@apply fill-foreground;
339+
}
340+
341+
/* Flowchart specific styles */
342+
& .flowchart-link {
343+
@apply stroke-foreground;
344+
}
345+
346+
/* Gantt chart specific styles */
347+
& .section0,
348+
& .section1,
349+
& .section2,
350+
& .section3 {
351+
@apply fill-muted;
352+
}
353+
354+
& .task0,
355+
& .task1,
356+
& .task2,
357+
& .task3 {
358+
@apply fill-primary;
359+
}
360+
361+
& .taskText0,
362+
& .taskText1,
363+
& .taskText2,
364+
& .taskText3 {
365+
@apply fill-primary-foreground;
366+
}
367+
368+
& .grid .tick {
369+
@apply stroke-border;
370+
}
371+
372+
& .grid .tick text {
373+
@apply fill-muted-foreground;
374+
}
375+
}
277376
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use client';
2+
3+
import { MermaidDiagram } from './MermaidDiagram';
4+
import type { Element } from 'hast';
5+
6+
interface CodeBlockProps extends React.HTMLAttributes<HTMLElement> {
7+
children?: React.ReactNode;
8+
className?: string;
9+
inline?: boolean;
10+
node?: Element;
11+
}
12+
13+
export const CodeBlock = ({
14+
children,
15+
className,
16+
inline,
17+
...props
18+
}: CodeBlockProps) => {
19+
// Extract language from className (format: "language-xxx")
20+
const match = /language-(\w+)/.exec(className || '');
21+
const language = match ? match[1] : '';
22+
23+
// Convert children to string
24+
const code = String(children).replace(/\n$/, '');
25+
26+
// If it's inline code or not mermaid, render as regular code
27+
if (inline || language !== 'mermaid') {
28+
return (
29+
<code className={className} {...props}>
30+
{children}
31+
</code>
32+
);
33+
}
34+
35+
// If it's a mermaid code block, render with MermaidDiagram
36+
if (language === 'mermaid') {
37+
return <MermaidDiagram chart={code} className="my-4" />;
38+
}
39+
40+
// Fallback to regular code block
41+
return (
42+
<code className={className} {...props}>
43+
{children}
44+
</code>
45+
);
46+
};
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
'use client';
2+
3+
import { useEffect, useState, useRef } from 'react';
4+
5+
interface MermaidDiagramProps {
6+
chart: string;
7+
className?: string;
8+
}
9+
10+
export const MermaidDiagram = ({
11+
chart,
12+
className = '',
13+
}: MermaidDiagramProps) => {
14+
const [isLoading, setIsLoading] = useState(true);
15+
const [error, setError] = useState<string | null>(null);
16+
const [svgContent, setSvgContent] = useState<string>('');
17+
const [isExpanded, setIsExpanded] = useState(false);
18+
const isMountedRef = useRef(true);
19+
20+
useEffect(() => {
21+
// Reset mounted flag when component mounts
22+
isMountedRef.current = true;
23+
24+
const renderDiagram = async () => {
25+
if (!chart.trim()) {
26+
if (isMountedRef.current) {
27+
setIsLoading(false);
28+
}
29+
return;
30+
}
31+
32+
try {
33+
if (isMountedRef.current) {
34+
setIsLoading(true);
35+
setError(null);
36+
setSvgContent('');
37+
}
38+
39+
const mermaid = (await import('mermaid')).default;
40+
41+
// Check if component is still mounted after async import
42+
if (!isMountedRef.current) return;
43+
44+
// Configure mermaid with base theme
45+
mermaid.initialize({
46+
startOnLoad: false,
47+
theme: 'base',
48+
suppressErrorRendering: true,
49+
});
50+
51+
// Generate unique ID
52+
const id = `mermaid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
53+
54+
// Render the diagram
55+
const { svg } = await mermaid.render(id, chart);
56+
57+
// Check if component is still mounted after async render
58+
if (!isMountedRef.current) return;
59+
60+
setSvgContent(svg);
61+
setIsLoading(false);
62+
} catch (err) {
63+
// Only update state if component is still mounted
64+
if (isMountedRef.current) {
65+
setError(
66+
err instanceof Error ? err.message : 'Failed to render diagram',
67+
);
68+
setIsLoading(false);
69+
}
70+
}
71+
};
72+
73+
renderDiagram();
74+
75+
// Cleanup function to mark component as unmounted
76+
return () => {
77+
isMountedRef.current = false;
78+
};
79+
}, [chart]);
80+
81+
return (
82+
<div
83+
className={`mermaid-diagram bg-background border border-border rounded-md p-4 overflow-x-auto ${className}`}
84+
style={{
85+
color: 'inherit',
86+
minHeight: '100px',
87+
}}
88+
>
89+
{error && (
90+
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
91+
<div className="text-yellow-800 text-sm font-medium mb-2">
92+
Mermaid Diagram Error
93+
</div>
94+
<div className="text-yellow-700 text-xs font-mono">{error}</div>
95+
<details className="mt-2">
96+
<summary className="text-yellow-600 text-xs cursor-pointer hover:text-yellow-800">
97+
Show diagram source
98+
</summary>
99+
<pre className="mt-2 text-xs bg-yellow-50 p-2 rounded border border-yellow-200 overflow-x-auto">
100+
<code>{chart}</code>
101+
</pre>
102+
</details>
103+
</div>
104+
)}
105+
106+
{isLoading && !error && (
107+
<div className="flex items-center justify-center min-h-[100px]">
108+
<div className="flex items-center gap-2 text-muted-foreground">
109+
<div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
110+
<span className="text-sm">Rendering diagram...</span>
111+
</div>
112+
</div>
113+
)}
114+
115+
{svgContent && !isLoading && !error && (
116+
<div className="relative">
117+
<div
118+
dangerouslySetInnerHTML={{ __html: svgContent }}
119+
className={`mermaid-svg-container transition-all duration-300 cursor-pointer ${
120+
isExpanded ? '' : 'max-h-96 overflow-hidden'
121+
}`}
122+
onClick={() => setIsExpanded(!isExpanded)}
123+
title={isExpanded ? 'Click to collapse' : 'Click to expand'}
124+
/>
125+
{!isExpanded && (
126+
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent pointer-events-none" />
127+
)}
128+
<div className="mt-2 text-center">
129+
<button
130+
onClick={() => setIsExpanded(!isExpanded)}
131+
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded border border-border hover:bg-muted"
132+
>
133+
{isExpanded ? '↑ Collapse diagram' : '↓ Expand diagram'}
134+
</button>
135+
</div>
136+
</div>
137+
)}
138+
</div>
139+
);
140+
};

0 commit comments

Comments
 (0)