fix: restore mermaid labels in markdown preview (#659)

This commit is contained in:
Jinwoo Hong 2026-04-14 20:06:08 -04:00 committed by GitHub
parent cfe49ff404
commit 8c016c91b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 65 additions and 11 deletions

View file

@ -280,10 +280,14 @@ export default function MarkdownPreview({
},
// Why: Intercept code elements to detect mermaid fenced blocks. rehype-highlight
// sets className="language-mermaid" on the <code> inside <pre> for ```mermaid blocks.
// We render those as SVG diagrams instead of highlighted source.
// We render those as SVG diagrams instead of highlighted source. Markdown preview
// opts out of Mermaid HTML labels because this path sanitizes the SVG before
// injection, and sanitized foreignObject labels disappear on some platforms.
code: ({ className, children, ...props }) => {
if (/language-mermaid/.test(className || '')) {
return <MermaidBlock content={String(children).trimEnd()} isDark={isDark} />
return (
<MermaidBlock content={String(children).trimEnd()} isDark={isDark} htmlLabels={false} />
)
}
return (
<code className={className} {...props}>

View file

@ -1,10 +1,12 @@
import React, { useEffect, useId, useRef, useState } from 'react'
import mermaid from 'mermaid'
import DOMPurify from 'dompurify'
import { getMermaidConfig } from './mermaid-config'
type MermaidBlockProps = {
content: string
isDark: boolean
htmlLabels?: boolean
}
// Why: mermaid.render() manipulates global DOM state (element IDs, internal
@ -17,21 +19,26 @@ let renderQueue: Promise<void> = Promise.resolve()
* Renders a mermaid diagram string as SVG. Falls back to raw source with an
* error banner if the syntax is invalid never breaks the rest of the preview.
*/
export default function MermaidBlock({ content, isDark }: MermaidBlockProps): React.JSX.Element {
export default function MermaidBlock({
content,
isDark,
htmlLabels = true
}: MermaidBlockProps): React.JSX.Element {
const id = useId().replace(/:/g, '_')
const containerRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const theme = isDark ? 'dark' : 'default'
// Re-initialize on every effect so the theme stays in sync with the
// current appearance. mermaid.initialize() is cheap and idempotent.
mermaid.initialize({ startOnLoad: false, theme })
let cancelled = false
const render = async (): Promise<void> => {
try {
// Why: Mermaid stores initialize() config in global module state. Apply
// the config inside the same serialized render task so another
// MermaidBlock cannot overwrite htmlLabels/theme between initialize()
// and render(), which would make markdown preview fall back to the
// broken foreignObject label path again.
mermaid.initialize(getMermaidConfig(isDark, htmlLabels))
const { svg } = await mermaid.render(`mermaid-${id}`, content)
if (!cancelled && containerRef.current) {
// Why: although mermaid uses DOMPurify internally, we add an explicit
@ -58,7 +65,7 @@ export default function MermaidBlock({ content, isDark }: MermaidBlockProps): Re
return () => {
cancelled = true
}
}, [content, isDark, id])
}, [content, htmlLabels, isDark, id])
if (error) {
return (

View file

@ -113,10 +113,12 @@ export function RichMarkdownCodeBlock({
<NodeViewContent<'pre'> as="pre" />
{/* Why: mermaid diagrams render as a live SVG preview below the editable
source so users can see the result while editing. The code block stays
editable the diagram is read-only output. */}
editable the diagram is read-only output. This preview also goes
through MermaidBlock's sanitized SVG path, so it must opt out of
Mermaid HTML labels just like markdown preview to keep labels visible. */}
{isMermaid && node.textContent.trim() && (
<div contentEditable={false} className="mermaid-preview">
<MermaidBlock content={node.textContent.trim()} isDark={isDark} />
<MermaidBlock content={node.textContent.trim()} isDark={isDark} htmlLabels={false} />
</div>
)}
</NodeViewWrapper>

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { getMermaidConfig } from './mermaid-config'
describe('getMermaidConfig', () => {
it('keeps Mermaid HTML labels enabled by default', () => {
expect(getMermaidConfig(false)).toMatchObject({
startOnLoad: false,
theme: 'default',
htmlLabels: true
})
})
it('can disable HTML labels for sanitized preview paths', () => {
expect(getMermaidConfig(false, false)).toMatchObject({
startOnLoad: false,
theme: 'default',
htmlLabels: false
})
})
it('switches to the dark mermaid theme when the preview is dark', () => {
expect(getMermaidConfig(true)).toMatchObject({
theme: 'dark',
htmlLabels: true
})
})
})

View file

@ -0,0 +1,12 @@
import type mermaid from 'mermaid'
export function getMermaidConfig(
isDark: boolean,
htmlLabels = true
): Parameters<typeof mermaid.initialize>[0] {
return {
startOnLoad: false,
theme: isDark ? 'dark' : 'default',
htmlLabels
}
}

View file

@ -4,6 +4,7 @@ declare module 'mermaid' {
type MermaidInitializeOptions = {
startOnLoad?: boolean
theme?: MermaidTheme
htmlLabels?: boolean
}
type MermaidRenderResult = {