Skip to content

Commit 180ddd3

Browse files
fix mermaid diagrams (#855)
1 parent 3cd2b24 commit 180ddd3

3 files changed

Lines changed: 177 additions & 0 deletions

File tree

src/components/markdown/CodeBlock.server.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
import { CodeBlockView } from './CodeBlockView'
2+
import { MermaidBlock } from './MermaidBlock'
23
import type { CodeBlockProps } from './codeBlock.shared'
34
import { extractCodeBlockData } from './codeBlock.shared'
45
import { renderCodeBlockData } from './renderCodeBlock.server'
56

67
export async function CodeBlock(props: CodeBlockProps) {
78
const { code, lang, title } = extractCodeBlockData(props)
9+
10+
if (lang === 'mermaid') {
11+
return (
12+
<MermaidBlock
13+
className={props.className}
14+
code={code}
15+
isEmbedded={props.isEmbedded}
16+
showTypeCopyButton={props.showTypeCopyButton}
17+
style={props.style}
18+
title={title}
19+
/>
20+
)
21+
}
22+
823
const rendered = await renderCodeBlockData({
924
code,
1025
lang,

src/components/markdown/CodeBlock.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import * as React from 'react'
44
import { CodeBlockView } from './CodeBlockView'
5+
import { MermaidBlock } from './MermaidBlock'
56
import {
67
buildPlainCodeBlockHtml,
78
extractCodeBlockData,
@@ -30,6 +31,36 @@ function getRenderPromise(
3031

3132
export function CodeBlock(props: CodeBlockProps) {
3233
const { code, lang, title } = extractCodeBlockData(props)
34+
35+
if (lang === 'mermaid') {
36+
return (
37+
<MermaidBlock
38+
className={props.className}
39+
code={code}
40+
isEmbedded={props.isEmbedded}
41+
showTypeCopyButton={props.showTypeCopyButton}
42+
style={props.style}
43+
title={title}
44+
/>
45+
)
46+
}
47+
48+
return (
49+
<HighlightedCodeBlock code={code} lang={lang} props={props} title={title} />
50+
)
51+
}
52+
53+
function HighlightedCodeBlock({
54+
code,
55+
lang,
56+
props,
57+
title,
58+
}: {
59+
code: string
60+
lang: string
61+
props: CodeBlockProps
62+
title?: string
63+
}) {
3364
const [rendered, setRendered] = React.useState<RenderedCodeBlockData | null>(
3465
null,
3566
)
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
'use client'
2+
3+
import * as React from 'react'
4+
import { twMerge } from 'tailwind-merge'
5+
import { CodeBlockView } from './CodeBlockView'
6+
import { buildPlainCodeBlockHtml } from './codeBlock.shared'
7+
8+
type MermaidRenderState =
9+
| {
10+
status: 'loading'
11+
svg?: undefined
12+
}
13+
| {
14+
status: 'rendered'
15+
svg: string
16+
}
17+
| {
18+
status: 'error'
19+
svg?: undefined
20+
}
21+
22+
function getIsDarkMode() {
23+
return document.documentElement.classList.contains('dark')
24+
}
25+
26+
function useIsDarkMode() {
27+
const [isDark, setIsDark] = React.useState(false)
28+
29+
React.useEffect(() => {
30+
const updateIsDark = () => setIsDark(getIsDarkMode())
31+
updateIsDark()
32+
33+
const observer = new MutationObserver(updateIsDark)
34+
observer.observe(document.documentElement, {
35+
attributeFilter: ['class'],
36+
attributes: true,
37+
})
38+
39+
return () => observer.disconnect()
40+
}, [])
41+
42+
return isDark
43+
}
44+
45+
export function MermaidBlock({
46+
className,
47+
code,
48+
isEmbedded,
49+
showTypeCopyButton,
50+
style,
51+
title,
52+
}: {
53+
className?: string
54+
code: string
55+
isEmbedded?: boolean
56+
showTypeCopyButton?: boolean
57+
style?: React.CSSProperties
58+
title?: string
59+
}) {
60+
const isDark = useIsDarkMode()
61+
const reactId = React.useId()
62+
const mermaidId = React.useMemo(
63+
() => `mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, '')}`,
64+
[reactId],
65+
)
66+
const [renderState, setRenderState] = React.useState<MermaidRenderState>({
67+
status: 'loading',
68+
})
69+
70+
React.useEffect(() => {
71+
let cancelled = false
72+
73+
async function renderMermaid() {
74+
setRenderState({ status: 'loading' })
75+
76+
try {
77+
const mermaid = (await import('mermaid')).default
78+
79+
mermaid.initialize({
80+
securityLevel: 'strict',
81+
startOnLoad: false,
82+
theme: isDark ? 'dark' : 'default',
83+
})
84+
85+
const { svg } = await mermaid.render(mermaidId, code)
86+
87+
if (!cancelled) {
88+
setRenderState({ status: 'rendered', svg })
89+
}
90+
} catch {
91+
if (!cancelled) {
92+
setRenderState({ status: 'error' })
93+
}
94+
}
95+
}
96+
97+
void renderMermaid()
98+
99+
return () => {
100+
cancelled = true
101+
}
102+
}, [code, isDark, mermaidId])
103+
104+
if (renderState.status !== 'rendered') {
105+
return (
106+
<CodeBlockView
107+
className={className}
108+
copyText={code.trimEnd()}
109+
htmlMarkup={buildPlainCodeBlockHtml(code)}
110+
isEmbedded={isEmbedded}
111+
lang="mermaid"
112+
showTypeCopyButton={showTypeCopyButton}
113+
style={style}
114+
title={title}
115+
/>
116+
)
117+
}
118+
119+
return (
120+
<div
121+
className={twMerge(
122+
'mermaid-block not-prose w-full max-w-full overflow-x-auto rounded-md border border-gray-500/20 bg-white p-4 dark:bg-gray-950 [&_svg]:mx-auto [&_svg]:max-w-none',
123+
className,
124+
)}
125+
style={style}
126+
role="img"
127+
aria-label={title ?? 'Mermaid diagram'}
128+
dangerouslySetInnerHTML={{ __html: renderState.svg }}
129+
/>
130+
)
131+
}

0 commit comments

Comments
 (0)