MAESTRO: Add Mermaid diagram rendering to Markdown viewers

Integrated the mermaid package to render diagrams in fenced code blocks
marked as `mermaid`. Both FilePreview and Scratchpad components now
support Mermaid diagrams with proper theme detection and DOMPurify
sanitization for security.
This commit is contained in:
Pedram Amini
2025-11-26 15:58:28 -06:00
parent 758536f930
commit 35cce4d88a
5 changed files with 1503 additions and 4 deletions

1296
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -97,6 +97,7 @@
"dompurify": "^3.3.0",
"electron-store": "^8.1.0",
"fastify": "^4.25.2",
"mermaid": "^11.12.1",
"node-pty": "^1.0.0",
"react-diff-view": "^3.3.2",
"react-markdown": "^10.1.0",

View File

@@ -7,6 +7,7 @@ import { FileCode, X, Copy, FileText, Eye, ChevronUp, ChevronDown } from 'lucide
import { visit } from 'unist-util-visit';
import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
import { MermaidRenderer } from './MermaidRenderer';
interface FilePreviewProps {
file: { name: string; content: string; path: string } | null;
@@ -645,6 +646,12 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
code: ({ node, inline, className, children, ...props }) => {
const match = (className || '').match(/language-(\w+)/);
const language = match ? match[1] : 'text';
const codeContent = String(children).replace(/\n$/, '');
// Handle mermaid code blocks
if (!inline && language === 'mermaid') {
return <MermaidRenderer chart={codeContent} theme={theme} />;
}
return !inline && match ? (
<SyntaxHighlighter
@@ -659,7 +666,7 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
}}
PreTag="div"
>
{String(children).replace(/\n$/, '')}
{codeContent}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>

View File

@@ -0,0 +1,161 @@
import React, { useEffect, useRef, useState } from 'react';
import mermaid from 'mermaid';
import DOMPurify from 'dompurify';
interface MermaidRendererProps {
chart: string;
theme: any;
}
// Initialize mermaid with custom theme settings
const initMermaid = (isDarkTheme: boolean) => {
mermaid.initialize({
startOnLoad: false,
theme: isDarkTheme ? 'dark' : 'default',
securityLevel: 'strict',
fontFamily: 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace',
flowchart: {
useMaxWidth: true,
htmlLabels: true,
curve: 'basis'
},
sequence: {
useMaxWidth: true,
diagramMarginX: 8,
diagramMarginY: 8
},
gantt: {
useMaxWidth: true
}
});
};
// Sanitize and parse SVG into safe DOM nodes
const createSanitizedSvgElement = (svgString: string): Node | null => {
// First sanitize with DOMPurify configured for SVG
const sanitized = DOMPurify.sanitize(svgString, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['foreignObject'],
ADD_ATTR: ['xmlns', 'xmlns:xlink', 'xlink:href', 'dominant-baseline', 'text-anchor'],
RETURN_DOM: true
});
// Return the first child (the SVG element)
return sanitized.firstChild;
};
export function MermaidRenderer({ chart, theme }: MermaidRendererProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const renderChart = async () => {
if (!containerRef.current || !chart.trim()) return;
setIsLoading(true);
setError(null);
// Determine if theme is dark by checking background color
const isDarkTheme = theme.colors.bgMain.toLowerCase().includes('#1') ||
theme.colors.bgMain.toLowerCase().includes('#2') ||
theme.colors.bgMain.toLowerCase().includes('#0');
// Initialize mermaid with the current theme
initMermaid(isDarkTheme);
try {
// Generate a unique ID for this diagram
const id = `mermaid-${Math.random().toString(36).substring(2, 11)}`;
// Render the diagram
const { svg: renderedSvg } = await mermaid.render(id, chart.trim());
// Create sanitized DOM element from SVG string
const svgElement = createSanitizedSvgElement(renderedSvg);
// Clear container and append sanitized SVG
while (containerRef.current.firstChild) {
containerRef.current.removeChild(containerRef.current.firstChild);
}
if (svgElement) {
containerRef.current.appendChild(svgElement);
}
setError(null);
} catch (err) {
console.error('Mermaid rendering error:', err);
setError(err instanceof Error ? err.message : 'Failed to render diagram');
// Clear container on error
if (containerRef.current) {
while (containerRef.current.firstChild) {
containerRef.current.removeChild(containerRef.current.firstChild);
}
}
} finally {
setIsLoading(false);
}
};
renderChart();
}, [chart, theme.colors.bgMain]);
if (isLoading) {
return (
<div
className="p-4 rounded-lg text-center text-sm"
style={{
backgroundColor: theme.colors.bgActivity,
color: theme.colors.textDim
}}
>
Rendering diagram...
</div>
);
}
if (error) {
return (
<div
className="p-4 rounded-lg border"
style={{
backgroundColor: theme.colors.bgActivity,
borderColor: theme.colors.error,
color: theme.colors.error
}}
>
<div className="text-sm font-medium mb-2">Failed to render Mermaid diagram</div>
<pre className="text-xs whitespace-pre-wrap opacity-75">{error}</pre>
<details className="mt-3">
<summary
className="text-xs cursor-pointer"
style={{ color: theme.colors.textDim }}
>
View source
</summary>
<pre
className="mt-2 p-2 text-xs rounded overflow-x-auto"
style={{
backgroundColor: theme.colors.bgMain,
color: theme.colors.textMain
}}
>
{chart}
</pre>
</details>
</div>
);
}
return (
<div
ref={containerRef}
className="mermaid-container p-4 rounded-lg overflow-x-auto"
style={{
backgroundColor: theme.colors.bgActivity
}}
/>
);
}

View File

@@ -1,9 +1,12 @@
import React, { useState, useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { Eye, Edit, Play, Square, Loader2, HelpCircle } from 'lucide-react';
import type { BatchRunState } from '../types';
import { AutoRunnerHelpModal } from './AutoRunnerHelpModal';
import { MermaidRenderer } from './MermaidRenderer';
interface ScratchpadProps {
content: string;
@@ -390,7 +393,42 @@ export function Scratchpad({
margin-left: -1.5em;
}
`}</style>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code: ({ node, inline, className, children, ...props }: any) => {
const match = (className || '').match(/language-(\w+)/);
const language = match ? match[1] : 'text';
const codeContent = String(children).replace(/\n$/, '');
// Handle mermaid code blocks
if (!inline && language === 'mermaid') {
return <MermaidRenderer chart={codeContent} theme={theme} />;
}
return !inline && match ? (
<SyntaxHighlighter
language={language}
style={vscDarkPlus}
customStyle={{
margin: '0.5em 0',
padding: '1em',
background: theme.colors.bgActivity,
fontSize: '0.9em',
borderRadius: '6px',
}}
PreTag="div"
>
{codeContent}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
}
}}
>
{content || '*No content yet. Switch to Edit mode to start writing.*'}
</ReactMarkdown>
</div>