Skip to content

Commit 74a4db7

Browse files
authored
Merge pull request #76 from codervisor/copilot/implement-spec-119
Implement native Mermaid diagram rendering in spec detail view
2 parents dd66fd6 + 11189f1 commit 74a4db7

File tree

6 files changed

+866
-4
lines changed

6 files changed

+866
-4
lines changed

packages/ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"highlight.js": "^11.11.1",
7979
"js-yaml": "^4.1.0",
8080
"lucide-react": "^0.553.0",
81+
"mermaid": "^11.12.1",
8182
"next": "16.0.1",
8283
"next-themes": "^0.4.6",
8384
"open": "^11.0.0",
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Tests for MermaidDiagram component logic
3+
*/
4+
5+
import { describe, it, expect } from 'vitest';
6+
7+
/**
8+
* Helper function to extract mermaid code from a pre/code block
9+
* This mirrors the logic used in spec-detail-client.tsx to determine
10+
* if a code block should be rendered as a mermaid diagram
11+
*/
12+
function shouldRenderAsMermaid(className: string | undefined): boolean {
13+
if (!className) return false;
14+
return className.includes('language-mermaid');
15+
}
16+
17+
/**
18+
* Helper function to extract the mermaid code from child content
19+
* Returns the code as a string, trimmed for rendering
20+
*/
21+
function extractMermaidCode(children: unknown): string {
22+
if (typeof children === 'string') {
23+
return children.trim();
24+
}
25+
return '';
26+
}
27+
28+
describe('Mermaid diagram detection', () => {
29+
it('detects mermaid language class', () => {
30+
expect(shouldRenderAsMermaid('language-mermaid')).toBe(true);
31+
expect(shouldRenderAsMermaid('hljs language-mermaid')).toBe(true);
32+
expect(shouldRenderAsMermaid('language-mermaid hljs')).toBe(true);
33+
});
34+
35+
it('does not match other language classes', () => {
36+
expect(shouldRenderAsMermaid('language-javascript')).toBe(false);
37+
expect(shouldRenderAsMermaid('language-typescript')).toBe(false);
38+
expect(shouldRenderAsMermaid('language-markdown')).toBe(false);
39+
expect(shouldRenderAsMermaid(undefined)).toBe(false);
40+
expect(shouldRenderAsMermaid('')).toBe(false);
41+
});
42+
});
43+
44+
describe('Mermaid code extraction', () => {
45+
it('extracts string content directly', () => {
46+
const code = 'graph TD\n A --> B';
47+
expect(extractMermaidCode(code)).toBe('graph TD\n A --> B');
48+
});
49+
50+
it('trims whitespace from content', () => {
51+
const code = ' \ngraph TD\n A --> B\n ';
52+
expect(extractMermaidCode(code)).toBe('graph TD\n A --> B');
53+
});
54+
55+
it('returns empty string for non-string content', () => {
56+
expect(extractMermaidCode(null)).toBe('');
57+
expect(extractMermaidCode(undefined)).toBe('');
58+
expect(extractMermaidCode(123)).toBe('');
59+
expect(extractMermaidCode({})).toBe('');
60+
});
61+
});
62+
63+
describe('Mermaid diagram types', () => {
64+
const validDiagrams = [
65+
// Flowchart
66+
'graph TD\n A --> B',
67+
'graph LR\n A --> B --> C',
68+
'flowchart TB\n Start --> Stop',
69+
// Sequence diagram
70+
'sequenceDiagram\n Alice->>John: Hello John',
71+
// Class diagram
72+
'classDiagram\n Class01 <|-- Class02',
73+
// State diagram
74+
'stateDiagram-v2\n [*] --> Still',
75+
// ER diagram
76+
'erDiagram\n CUSTOMER ||--o{ ORDER : places',
77+
// Gantt chart
78+
'gantt\n title A Gantt Diagram\n section Section',
79+
// Pie chart
80+
'pie title Pets\n "Dogs" : 386',
81+
];
82+
83+
validDiagrams.forEach((diagram) => {
84+
const type = diagram.split('\n')[0].split(' ')[0];
85+
it(`recognizes ${type} diagram syntax`, () => {
86+
// These are valid mermaid diagrams that should be detected
87+
const extracted = extractMermaidCode(diagram);
88+
expect(extracted).toBeTruthy();
89+
expect(extracted.length).toBeGreaterThan(0);
90+
});
91+
});
92+
});
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
import { useTheme } from 'next-themes';
5+
import { Skeleton } from '@/components/ui/skeleton';
6+
import { Button } from '@/components/ui/button';
7+
import { Code2, AlertCircle } from 'lucide-react';
8+
9+
interface MermaidDiagramProps {
10+
code: string;
11+
}
12+
13+
// Counter for generating unique IDs
14+
let diagramIdCounter = 0;
15+
16+
export function MermaidDiagram({ code }: MermaidDiagramProps) {
17+
const containerRef = React.useRef<HTMLDivElement>(null);
18+
const [svg, setSvg] = React.useState<string>('');
19+
const [error, setError] = React.useState<string | null>(null);
20+
const [isLoading, setIsLoading] = React.useState(true);
21+
const [showSource, setShowSource] = React.useState(false);
22+
const { resolvedTheme } = useTheme();
23+
const [mounted, setMounted] = React.useState(false);
24+
25+
// Track mount state for SSR safety
26+
React.useEffect(() => {
27+
setMounted(true);
28+
}, []);
29+
30+
// Render the mermaid diagram
31+
React.useEffect(() => {
32+
if (!mounted) return;
33+
34+
const renderDiagram = async () => {
35+
setIsLoading(true);
36+
setError(null);
37+
38+
try {
39+
// Dynamically import mermaid to avoid SSR issues
40+
const mermaid = (await import('mermaid')).default;
41+
42+
mermaid.initialize({
43+
startOnLoad: false,
44+
theme: resolvedTheme === 'dark' ? 'dark' : 'default',
45+
// Use 'strict' for security - specs are developer-authored
46+
// but we still want to prevent potential XSS from code blocks
47+
securityLevel: 'strict',
48+
fontFamily: 'inherit',
49+
});
50+
51+
// Generate unique ID for the diagram using counter
52+
const id = `mermaid-${Date.now()}-${++diagramIdCounter}`;
53+
const cleanedCode = code.trim();
54+
55+
const { svg: renderedSvg } = await mermaid.render(id, cleanedCode);
56+
setSvg(renderedSvg);
57+
setError(null);
58+
} catch (err) {
59+
const errorMessage = err instanceof Error ? err.message : 'Diagram render failed';
60+
setError(errorMessage);
61+
setSvg('');
62+
} finally {
63+
setIsLoading(false);
64+
}
65+
};
66+
67+
renderDiagram();
68+
}, [code, resolvedTheme, mounted]);
69+
70+
// Show skeleton during SSR or initial mount
71+
if (!mounted) {
72+
return <DiagramSkeleton />;
73+
}
74+
75+
// Show loading state
76+
if (isLoading) {
77+
return <DiagramSkeleton />;
78+
}
79+
80+
// Show error state with fallback to source
81+
if (error) {
82+
return (
83+
<div className="my-4 border border-destructive/50 bg-destructive/10 rounded-md overflow-hidden">
84+
<div className="flex items-center gap-2 px-4 py-2 bg-destructive/20 border-b border-destructive/30">
85+
<AlertCircle className="h-4 w-4 text-destructive" />
86+
<span className="text-sm font-medium text-destructive">Diagram error</span>
87+
</div>
88+
<div className="p-4">
89+
<p className="text-sm text-destructive mb-3">{error}</p>
90+
<pre className="text-xs overflow-x-auto bg-muted/50 p-3 rounded border">
91+
<code>{code}</code>
92+
</pre>
93+
</div>
94+
</div>
95+
);
96+
}
97+
98+
// Show rendered diagram with source toggle
99+
return (
100+
<div className="my-4 border rounded-md overflow-hidden">
101+
<div className="flex items-center justify-end px-3 py-1.5 bg-muted/30 border-b">
102+
<Button
103+
variant="ghost"
104+
size="sm"
105+
onClick={() => setShowSource(!showSource)}
106+
className="h-7 text-xs"
107+
>
108+
<Code2 className="h-3.5 w-3.5 mr-1.5" />
109+
{showSource ? 'View Diagram' : 'View Source'}
110+
</Button>
111+
</div>
112+
113+
{showSource ? (
114+
<pre className="text-xs overflow-x-auto p-4 bg-muted/20">
115+
<code className="language-mermaid">{code}</code>
116+
</pre>
117+
) : (
118+
<div
119+
ref={containerRef}
120+
className="flex justify-center overflow-x-auto p-4 bg-background"
121+
dangerouslySetInnerHTML={{ __html: svg }}
122+
/>
123+
)}
124+
</div>
125+
);
126+
}
127+
128+
function DiagramSkeleton() {
129+
return (
130+
<div className="my-4 border rounded-md overflow-hidden">
131+
<div className="flex items-center justify-end px-3 py-1.5 bg-muted/30 border-b">
132+
<Skeleton className="h-7 w-24" />
133+
</div>
134+
<div className="flex justify-center items-center p-8">
135+
<Skeleton className="h-48 w-full max-w-md" />
136+
</div>
137+
</div>
138+
);
139+
}

packages/ui/src/components/spec-detail-client.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { MarkdownLink } from '@/components/markdown-link';
2121
import { TableOfContents, TableOfContentsSidebar } from '@/components/table-of-contents';
2222
import { BackToTop } from '@/components/back-to-top';
2323
import { SpecDependencyGraph } from '@/components/spec-dependency-graph';
24+
import { MermaidDiagram } from '@/components/mermaid-diagram';
2425
import {
2526
Dialog,
2627
DialogContent,
@@ -411,6 +412,27 @@ export function SpecDetailClient({ initialSpec, initialSubSpec }: SpecDetailClie
411412
rehypePlugins={[rehypeHighlight, rehypeSlug]}
412413
components={{
413414
a: (props) => <MarkdownLink {...props} currentSpecNumber={spec.specNumber || undefined} />,
415+
pre: ({ children, ...props }) => {
416+
// Safely get the first child element
417+
const childArray = React.Children.toArray(children);
418+
const firstChild = childArray[0];
419+
420+
// Check if this is a mermaid code block
421+
if (
422+
React.isValidElement(firstChild) &&
423+
firstChild.type === 'code' &&
424+
typeof (firstChild.props as { className?: string }).className === 'string' &&
425+
(firstChild.props as { className?: string }).className?.includes('language-mermaid')
426+
) {
427+
const codeProps = firstChild.props as { children?: React.ReactNode };
428+
const code = typeof codeProps.children === 'string'
429+
? codeProps.children
430+
: '';
431+
return <MermaidDiagram code={code} />;
432+
}
433+
// Default pre rendering
434+
return <pre {...props}>{children}</pre>;
435+
},
414436
}}
415437
>
416438
{displayContent}

0 commit comments

Comments
 (0)