Render mermaid (#170)

This commit is contained in:
Matt Rubens 2025-07-01 14:16:38 -04:00 committed by GitHub
parent 08469f8ca3
commit 6c39ff9c2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1118 additions and 0 deletions

View file

@ -47,6 +47,7 @@
"import-in-the-middle": "^1.14.2",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.509.0",
"mermaid": "^11.7.0",
"next": "^15.3.3",
"next-intl": "^4.1.0",
"next-themes": "^0.4.6",
@ -79,6 +80,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/hast": "^3.0.4",
"@types/node": "^22.15.27",
"@types/pg": "^8.15.2",
"@types/react": "^19.1.6",

View file

@ -5,6 +5,7 @@ import type { Message } from '@/actions/analytics';
import { cn } from '@/lib/utils';
import { formatTimestamp } from '@/lib/formatters';
import { useAutoScroll } from '@/hooks/useAutoScroll';
import { CodeBlock } from '@/components/ui/CodeBlock';
// Custom component to render links as plain text to avoid broken/nonsensical links
const PlainTextLink = ({ children }: { children?: React.ReactNode }) => {
@ -146,6 +147,7 @@ export const Messages = ({ messages }: MessagesProps) => {
<ReactMarkdown
components={{
a: PlainTextLink,
code: CodeBlock,
}}
>
{message.text}

View file

@ -274,4 +274,103 @@
@apply italic;
}
}
.mermaid-diagram {
& svg {
@apply max-w-full h-auto;
pointer-events: none;
}
/* Allow pointer events on interactive elements within the SVG */
& svg a,
& svg button,
& svg [role='button'] {
pointer-events: auto;
}
/* Ensure mermaid diagrams respect theme colors */
& .node rect,
& .node circle,
& .node ellipse,
& .node polygon {
@apply fill-background stroke-border;
}
& .node .label {
@apply fill-foreground;
}
& .edgePath .path {
@apply stroke-foreground;
}
& .edgeLabel {
@apply fill-background;
}
& .cluster rect {
@apply fill-muted stroke-border;
}
& .titleText {
@apply fill-foreground;
}
/* Sequence diagram specific styles */
& .actor {
@apply fill-background stroke-border;
}
& .actor-line {
@apply stroke-border;
}
& .messageLine0,
& .messageLine1 {
@apply stroke-foreground;
}
& .messageText {
@apply fill-foreground;
}
& .loopText {
@apply fill-foreground;
}
/* Flowchart specific styles */
& .flowchart-link {
@apply stroke-foreground;
}
/* Gantt chart specific styles */
& .section0,
& .section1,
& .section2,
& .section3 {
@apply fill-muted;
}
& .task0,
& .task1,
& .task2,
& .task3 {
@apply fill-primary;
}
& .taskText0,
& .taskText1,
& .taskText2,
& .taskText3 {
@apply fill-primary-foreground;
}
& .grid .tick {
@apply stroke-border;
}
& .grid .tick text {
@apply fill-muted-foreground;
}
}
}

View file

@ -0,0 +1,46 @@
'use client';
import { MermaidDiagram } from './MermaidDiagram';
import type { Element } from 'hast';
interface CodeBlockProps extends React.HTMLAttributes<HTMLElement> {
children?: React.ReactNode;
className?: string;
inline?: boolean;
node?: Element;
}
export const CodeBlock = ({
children,
className,
inline,
...props
}: CodeBlockProps) => {
// Extract language from className (format: "language-xxx")
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
// Convert children to string
const code = String(children).replace(/\n$/, '');
// If it's inline code or not mermaid, render as regular code
if (inline || language !== 'mermaid') {
return (
<code className={className} {...props}>
{children}
</code>
);
}
// If it's a mermaid code block, render with MermaidDiagram
if (language === 'mermaid') {
return <MermaidDiagram chart={code} className="my-4" />;
}
// Fallback to regular code block
return (
<code className={className} {...props}>
{children}
</code>
);
};

View file

@ -0,0 +1,140 @@
'use client';
import { useEffect, useState, useRef } from 'react';
interface MermaidDiagramProps {
chart: string;
className?: string;
}
export const MermaidDiagram = ({
chart,
className = '',
}: MermaidDiagramProps) => {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [svgContent, setSvgContent] = useState<string>('');
const [isExpanded, setIsExpanded] = useState(false);
const isMountedRef = useRef(true);
useEffect(() => {
// Reset mounted flag when component mounts
isMountedRef.current = true;
const renderDiagram = async () => {
if (!chart.trim()) {
if (isMountedRef.current) {
setIsLoading(false);
}
return;
}
try {
if (isMountedRef.current) {
setIsLoading(true);
setError(null);
setSvgContent('');
}
const mermaid = (await import('mermaid')).default;
// Check if component is still mounted after async import
if (!isMountedRef.current) return;
// Configure mermaid with base theme
mermaid.initialize({
startOnLoad: false,
theme: 'base',
suppressErrorRendering: true,
});
// Generate unique ID
const id = `mermaid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Render the diagram
const { svg } = await mermaid.render(id, chart);
// Check if component is still mounted after async render
if (!isMountedRef.current) return;
setSvgContent(svg);
setIsLoading(false);
} catch (err) {
// Only update state if component is still mounted
if (isMountedRef.current) {
setError(
err instanceof Error ? err.message : 'Failed to render diagram',
);
setIsLoading(false);
}
}
};
renderDiagram();
// Cleanup function to mark component as unmounted
return () => {
isMountedRef.current = false;
};
}, [chart]);
return (
<div
className={`mermaid-diagram bg-background border border-border rounded-md p-4 overflow-x-auto ${className}`}
style={{
color: 'inherit',
minHeight: '100px',
}}
>
{error && (
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div className="text-yellow-800 text-sm font-medium mb-2">
Mermaid Diagram Error
</div>
<div className="text-yellow-700 text-xs font-mono">{error}</div>
<details className="mt-2">
<summary className="text-yellow-600 text-xs cursor-pointer hover:text-yellow-800">
Show diagram source
</summary>
<pre className="mt-2 text-xs bg-yellow-50 p-2 rounded border border-yellow-200 overflow-x-auto">
<code>{chart}</code>
</pre>
</details>
</div>
)}
{isLoading && !error && (
<div className="flex items-center justify-center min-h-[100px]">
<div className="flex items-center gap-2 text-muted-foreground">
<div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
<span className="text-sm">Rendering diagram...</span>
</div>
</div>
)}
{svgContent && !isLoading && !error && (
<div className="relative">
<div
dangerouslySetInnerHTML={{ __html: svgContent }}
className={`mermaid-svg-container transition-all duration-300 cursor-pointer ${
isExpanded ? '' : 'max-h-96 overflow-hidden'
}`}
onClick={() => setIsExpanded(!isExpanded)}
title={isExpanded ? 'Click to collapse' : 'Click to expand'}
/>
{!isExpanded && (
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent pointer-events-none" />
)}
<div className="mt-2 text-center">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded border border-border hover:bg-muted"
>
{isExpanded ? '↑ Collapse diagram' : '↓ Expand diagram'}
</button>
</div>
</div>
)}
</div>
);
};

829
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff