Skip to content

Commit 40ac98f

Browse files
committed
feat: add PreviewableCode component with Mermaid and PlantUML support
1 parent 22bc1d9 commit 40ac98f

File tree

7 files changed

+1702
-81
lines changed

7 files changed

+1702
-81
lines changed

components/MDXComponents.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
import TOCInline from 'pliny/ui/TOCInline'
2-
import Pre from 'pliny/ui/Pre'
2+
// import Pre from 'pliny/ui/Pre'
33
import BlogNewsletterForm from 'pliny/ui/BlogNewsletterForm'
44
import type { MDXComponents } from 'mdx/types'
55
import Image from './Image'
66
import CustomLink from './Link'
77
import TableWrapper from './TableWrapper'
8+
import PreviewableCode from './PreviewableCode'
9+
import Mermaid from './Mermaid'
10+
import PlantUML from './PlantUML'
11+
12+
// Configure available renderers
13+
const renderers = {
14+
mermaid: Mermaid,
15+
plantuml: PlantUML,
16+
}
817

918
export const components: MDXComponents = {
1019
Image,
1120
TOCInline,
1221
a: CustomLink,
13-
pre: Pre,
22+
pre: (props) => <PreviewableCode defaultView={'code'} renderers={renderers} {...props} />,
1423
table: TableWrapper,
1524
BlogNewsletterForm,
16-
}
25+
}

components/Mermaid.tsx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
'use client'
2+
3+
import { useEffect, useState } from 'react'
4+
import mermaid from 'mermaid'
5+
6+
// Module-level initialization flag
7+
let mermaidInitialized = false
8+
9+
const initializeMermaid = () => {
10+
if (!mermaidInitialized) {
11+
mermaid.initialize({
12+
startOnLoad: true,
13+
theme: 'base',
14+
logLevel: 'error',
15+
securityLevel: 'loose',
16+
suppressErrorRendering: true,
17+
flowchart: {
18+
curve: 'basis',
19+
padding: 20,
20+
nodeSpacing: 50,
21+
rankSpacing: 50,
22+
htmlLabels: true,
23+
defaultRenderer: 'dagre-wrapper',
24+
},
25+
themeVariables: {
26+
fontFamily:
27+
'ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif',
28+
primaryColor: '#6366f1',
29+
primaryTextColor: '#ffffff',
30+
primaryBorderColor: '#4f46e5',
31+
lineColor: '#6366f1',
32+
secondaryColor: '#f1f5f9',
33+
tertiaryColor: '#e2e8f0',
34+
},
35+
})
36+
mermaidInitialized = true
37+
}
38+
}
39+
40+
interface ErrorDisplayProps {
41+
error: Error | string
42+
code: string
43+
}
44+
45+
const ErrorDisplay = ({ error, code }: ErrorDisplayProps) => (
46+
<div className="overflow-hidden rounded-lg border border-red-200">
47+
<details className="group">
48+
<summary className="cursor-pointer list-none border-b border-red-200 bg-red-50 px-4 py-2">
49+
<div className="flex items-center">
50+
<span className="mr-2 text-red-500">⚠️</span>
51+
<span className="font-medium text-red-700">Mermaid Syntax Error</span>
52+
<svg
53+
className="ml-2 h-5 w-5 transform text-red-500 transition-transform group-open:rotate-180"
54+
fill="none"
55+
viewBox="0 0 24 24"
56+
stroke="currentColor"
57+
>
58+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
59+
</svg>
60+
</div>
61+
</summary>
62+
<div className="border-b border-red-200 bg-red-50 px-4 py-2">
63+
<pre className="whitespace-pre-wrap break-words font-mono text-sm font-medium text-red-700">
64+
{typeof error === 'string' ? error : error.message || 'Failed to render diagram'}
65+
</pre>
66+
</div>
67+
</details>
68+
<div className="bg-gray-50 p-4">
69+
<pre className="language-mermaid overflow-x-auto text-sm">{code}</pre>
70+
</div>
71+
</div>
72+
)
73+
74+
interface MermaidProps {
75+
code: string
76+
}
77+
78+
const Mermaid = ({ code }: MermaidProps) => {
79+
const [svg, setSvg] = useState<string>('')
80+
const [error, setError] = useState<Error | string | null>(null)
81+
82+
useEffect(() => {
83+
// Initialize mermaid once for the entire application
84+
initializeMermaid()
85+
86+
if (typeof code === 'string') {
87+
try {
88+
// Generate a unique ID for this diagram
89+
const id = `mermaid-${Math.random().toString(36).substr(2, 9)}`
90+
// Here we manually render the mermaid diagram, we can also let mermaid.js to automatically render the diagram
91+
// by return <pre className="language-mermaid">{chart}</pre>
92+
mermaid.render(id, code.trim()).then(
93+
({ svg }) => {
94+
setError(null)
95+
setSvg(svg)
96+
},
97+
(error) => {
98+
console.error('Mermaid rendering failed:', error)
99+
setError(error)
100+
setSvg('')
101+
}
102+
)
103+
} catch (error) {
104+
console.error('Mermaid error:', error)
105+
setError(error instanceof Error ? error : 'Failed to render diagram')
106+
setSvg('')
107+
}
108+
}
109+
}, [code])
110+
111+
if (error) {
112+
return <ErrorDisplay error={error} code={code} />
113+
}
114+
115+
return <div className="mermaid-chart my-4" dangerouslySetInnerHTML={{ __html: svg }} />
116+
}
117+
118+
export default Mermaid

components/PlantUML.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
'use client'
2+
3+
import { useEffect, useState } from 'react'
4+
import PlantUmlEncoder from 'plantuml-encoder'
5+
6+
interface PlantUMLProps {
7+
code: string
8+
dpi?: number
9+
}
10+
11+
const PlantUML = ({ code, dpi = 600 }: PlantUMLProps) => {
12+
const [url, setUrl] = useState<string>('')
13+
const [svgUrl, setSvgUrl] = useState<string>('')
14+
const [isHovered, setIsHovered] = useState(false)
15+
16+
useEffect(() => {
17+
if (typeof code === 'string') {
18+
const resolution = `skinparam dpi ${dpi}`
19+
const text = `${resolution}\n${code.trim()}`
20+
const encoded = PlantUmlEncoder.encode(text)
21+
setUrl(`https://www.plantuml.com/plantuml/img/${encoded}`)
22+
setSvgUrl(`https://www.plantuml.com/plantuml/svg/${encoded}`)
23+
}
24+
}, [code])
25+
26+
if (!url) {
27+
return null
28+
}
29+
30+
return (
31+
<div
32+
className="plantuml-chart relative mx-auto max-w-4xl"
33+
onMouseEnter={() => setIsHovered(true)}
34+
onMouseLeave={() => setIsHovered(false)}
35+
>
36+
<img
37+
src={url}
38+
alt="PlantUML diagram"
39+
className="mx-auto block w-full rounded-md object-contain"
40+
style={{ maxHeight: '80vh' }}
41+
/>
42+
<a
43+
href={svgUrl}
44+
target="_blank"
45+
rel="noopener noreferrer"
46+
className={`absolute right-2 bottom-2 rounded-md bg-white/80 px-2 py-1 text-xs text-gray-600 shadow-sm transition-all duration-200 hover:bg-white hover:text-gray-900 dark:bg-gray-800/80 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 ${
47+
isHovered ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'
48+
}`}
49+
>
50+
View SVG
51+
</a>
52+
</div>
53+
)
54+
}
55+
56+
export default PlantUML

components/PreviewableCode.tsx

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
'use client'
2+
3+
import type { ReactNode, ComponentType } from 'react'
4+
import Pre from 'pliny/ui/Pre'
5+
import { Tab, TabGroup, TabPanel, TabPanels, TabList } from '@headlessui/react'
6+
import { Fragment } from 'react'
7+
8+
interface PreviewableCodeBlock {
9+
type: string
10+
props: {
11+
className?: string
12+
children: ReactNode
13+
}
14+
}
15+
16+
const extractTextFromAST = (children: ReactNode): string => {
17+
if (typeof children === 'string') return children
18+
if (Array.isArray(children)) return children.map(extractTextFromAST).join('')
19+
if (children && typeof children === 'object' && 'props' in children) {
20+
const childrenWithProps = children as { props: { children: ReactNode } }
21+
return extractTextFromAST(childrenWithProps.props.children)
22+
}
23+
return String(children || '')
24+
}
25+
26+
const isPreviewableCodeBlock = (children: unknown): children is PreviewableCodeBlock => {
27+
return (
28+
children !== null &&
29+
typeof children === 'object' &&
30+
'props' in children &&
31+
'type' in children &&
32+
(children as any).type === 'code'
33+
)
34+
}
35+
36+
interface PreviewableCodeProps {
37+
children: ReactNode
38+
enablePreview?: boolean
39+
defaultView?: 'preview' | 'code'
40+
renderers: { [key: string]: ComponentType<any> }
41+
[key: string]: unknown
42+
}
43+
44+
const PreviewableCode = ({
45+
children,
46+
enablePreview = true,
47+
defaultView = 'preview',
48+
renderers,
49+
...props
50+
}: PreviewableCodeProps) => {
51+
// If preview is disabled or the code block is not previewable, return the raw code block
52+
if (!enablePreview || !isPreviewableCodeBlock(children)) {
53+
return <Pre {...props}>{children}</Pre>
54+
}
55+
56+
const { className } = children.props
57+
const languageMatch = /language-(\w+)/.exec(className || '')
58+
59+
if (!languageMatch) {
60+
return <Pre {...props}>{children}</Pre>
61+
}
62+
63+
const language = languageMatch[1]
64+
const Renderer = renderers[language]
65+
66+
if (!Renderer) {
67+
console.warn(`No renderer found for language: ${language}`)
68+
return <Pre {...props}>{children}</Pre>
69+
}
70+
71+
const rawText = extractTextFromAST(children.props.children)
72+
73+
return (
74+
<TabGroup defaultIndex={defaultView === 'preview' ? 0 : 1}>
75+
<div className="border-b border-gray-200 dark:border-gray-700">
76+
<TabList className="-mb-px flex space-x-8">
77+
<Tab as={Fragment}>
78+
{({ selected }) => (
79+
<button
80+
className={`border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
81+
selected
82+
? 'border-primary-500 text-primary-600 dark:text-primary-500'
83+
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
84+
} `}
85+
>
86+
Code
87+
</button>
88+
)}
89+
</Tab>
90+
<Tab as={Fragment}>
91+
{({ selected }) => (
92+
<button
93+
className={`border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
94+
selected
95+
? 'border-primary-500 text-primary-600 dark:text-primary-500'
96+
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
97+
} `}
98+
>
99+
Preview
100+
</button>
101+
)}
102+
</Tab>
103+
</TabList>
104+
</div>
105+
<TabPanels>
106+
<TabPanel>
107+
<Pre {...props}>{children}</Pre>
108+
</TabPanel>
109+
</TabPanels>
110+
<TabPanel>
111+
<div className="">
112+
<Renderer code={rawText} {...props} />
113+
</div>
114+
</TabPanel>
115+
</TabGroup>
116+
)
117+
}
118+
119+
export default PreviewableCode

0 commit comments

Comments
 (0)