feat(frontend): render mermaid markdown diagrams (#892)

* feat(frontend): render mermaid markdown diagrams

Signed-off-by: dongmucat <1127093059@qq.com>

* fix(frontend): clean up failed mermaid renders

Signed-off-by: dongmucat <1127093059@qq.com>

---------

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-09-21 14:08:41 +08:00 committed by GitHub
parent 6a89832bb7
commit 342d59472d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1141 additions and 10 deletions

View file

@ -0,0 +1,67 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { registerSession } from './helpers/session'
import { E2eTestDataBuilder } from './helpers/test-data-builder'
test.describe('Markdown Mermaid rendering (Real API)', () => {
test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
await registerSession(page, testInfo)
})
test('renders a valid Mermaid fence as an SVG diagram', async ({ page }, testInfo) => {
const builder = new E2eTestDataBuilder(page, testInfo)
await builder.init()
try {
const namespace = await builder.ensureWritableNamespace()
const skill = await builder.publishSkill(namespace.slug, {
name: `mermaid-valid-${Date.now().toString(36)}`,
readmeBody: [
'# Mermaid diagram',
'',
'```mermaid',
'flowchart TD',
' A[Start] --> B[Done]',
'```',
].join('\n'),
})
await page.goto(`/space/${encodeURIComponent(namespace.slug)}/${encodeURIComponent(skill.slug)}`)
await expect(page.locator('[data-mermaid-diagram] svg')).toBeVisible({ timeout: 30_000 })
await expect(page.locator('pre code.language-mermaid')).toHaveCount(0)
} finally {
await builder.cleanup()
}
})
test('keeps invalid Mermaid source visible when rendering fails', async ({ page }, testInfo) => {
const builder = new E2eTestDataBuilder(page, testInfo)
await builder.init()
try {
const namespace = await builder.ensureWritableNamespace()
const skill = await builder.publishSkill(namespace.slug, {
name: `mermaid-invalid-${Date.now().toString(36)}`,
readmeBody: [
'# Invalid Mermaid diagram',
'',
'```mermaid',
'this is not a Mermaid diagram',
'```',
].join('\n'),
})
await page.goto(`/space/${encodeURIComponent(namespace.slug)}/${encodeURIComponent(skill.slug)}`)
await expect(page.locator('[data-mermaid-error]')).toBeVisible()
const source = page.locator('pre code.language-mermaid')
await expect(source).toContainText('this is not a Mermaid diagram')
await expect(page.locator('[data-mermaid-diagram]')).toHaveCount(0)
await expect(page.locator('body > div[id^="dmermaid-"]')).toHaveCount(0)
} finally {
await builder.cleanup()
}
})
})

View file

@ -51,6 +51,7 @@
"i18next-browser-languagedetector": "^8.2.1",
"lowlight": "^3.3.0",
"lucide-react": "^0.344.0",
"mermaid": "^11.17.2",
"openapi-fetch": "^0.13.8",
"react": "^19.0.0",
"react-diff-viewer-continued": "^4.2.0",

836
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,32 @@
/** @vitest-environment jsdom */
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MARKDOWN_IMAGE_CLASS_NAME, MarkdownRenderer } from './markdown-renderer'
afterEach(() => cleanup())
const { mermaidInitialize, mermaidModuleLoaded, mermaidRender } = vi.hoisted(() => ({
mermaidInitialize: vi.fn(),
mermaidModuleLoaded: vi.fn(),
mermaidRender: vi.fn(),
}))
vi.mock('mermaid', () => {
mermaidModuleLoaded()
return {
default: {
initialize: mermaidInitialize,
render: mermaidRender,
},
}
})
afterEach(() => {
cleanup()
mermaidInitialize.mockClear()
mermaidModuleLoaded.mockClear()
mermaidRender.mockReset()
})
describe('MARKDOWN_IMAGE_CLASS_NAME', () => {
it('keeps markdown images at their intrinsic width while remaining responsive', () => {
@ -53,3 +75,81 @@ describe('MarkdownRenderer links', () => {
expect(container.firstElementChild).toBe(firstRoot)
})
})
describe('MarkdownRenderer Mermaid blocks', () => {
it('does not load Mermaid for documents without Mermaid blocks', () => {
render(<MarkdownRenderer content="Plain Markdown" />)
expect(mermaidModuleLoaded).not.toHaveBeenCalled()
})
it('renders Mermaid output outside the source code preformatted container', async () => {
mermaidRender.mockResolvedValue({ svg: '<svg data-testid="mermaid-svg"><path /></svg>' })
const { container } = render(
<MarkdownRenderer content={'```mermaid\nflowchart TD\nA-->B\n```'} />,
)
await waitFor(() => expect(container.querySelector('[data-testid="mermaid-svg"]')).toBeTruthy())
expect(mermaidInitialize).toHaveBeenCalledWith({
startOnLoad: false,
securityLevel: 'strict',
suppressErrorRendering: true,
})
expect(container.querySelector('[data-testid="mermaid-svg"]')?.closest('pre')).toBeNull()
})
it('keeps the original Mermaid source when rendering fails', async () => {
mermaidRender.mockRejectedValue(new Error('invalid Mermaid syntax'))
const { container } = render(
<MarkdownRenderer content={'```mermaid\nnot a valid diagram\n```'} />,
)
await waitFor(() => expect(container.querySelector('[data-mermaid-error]')).toBeTruthy())
expect(container.querySelector('pre code')?.textContent).toContain('not a valid diagram')
})
it('assigns different render IDs to Mermaid blocks in the same document', async () => {
mermaidRender.mockImplementation(async (id: string) => ({ svg: `<svg data-render-id="${id}" />` }))
render(
<MarkdownRenderer
content={'```mermaid\nflowchart TD\nA-->B\n```\n\n```mermaid\nflowchart LR\nC-->D\n```'}
/>,
)
await waitFor(() => expect(mermaidRender).toHaveBeenCalledTimes(2))
const ids = mermaidRender.mock.calls.map(([id]) => id)
expect(new Set(ids).size).toBe(2)
})
it('continues rendering later blocks after an earlier Mermaid render fails', async () => {
mermaidRender
.mockRejectedValueOnce(new Error('invalid Mermaid syntax'))
.mockResolvedValueOnce({ svg: '<svg data-testid="second-mermaid-svg" />' })
const { container } = render(
<MarkdownRenderer
content={'```mermaid\ninvalid\n```\n\n```mermaid\nflowchart LR\nA-->B\n```'}
/>,
)
await waitFor(() => expect(mermaidRender).toHaveBeenCalledTimes(2))
await waitFor(() => expect(container.querySelector('[data-testid="second-mermaid-svg"]')).toBeTruthy())
})
it('keeps ordinary fenced code in the existing preformatted container', () => {
const { container } = render(
<MarkdownRenderer content={'```typescript\nconst answer = 42\n```'} />,
)
const code = container.querySelector('pre code')
expect(code).not.toBeNull()
expect(code?.textContent).toContain('const answer = 42')
})
})

View file

@ -1,4 +1,15 @@
import { memo, useMemo, type MouseEvent } from 'react'
import {
Children,
isValidElement,
memo,
useEffect,
useMemo,
useRef,
useState,
type MouseEvent,
type ReactElement,
type ReactNode,
} from 'react'
import ReactMarkdown from 'react-markdown'
import rehypeHighlight from 'rehype-highlight'
import rehypeSanitize from 'rehype-sanitize'
@ -9,6 +20,117 @@ import { stripMarkdownFrontmatter } from './markdown-frontmatter'
export const MARKDOWN_IMAGE_CLASS_NAME = 'h-auto max-w-full'
type MermaidApi = typeof import('mermaid').default
let mermaidPromise: Promise<MermaidApi> | undefined
let mermaidRenderQueue: Promise<void> = Promise.resolve()
let mermaidBlockSequence = 0
function loadMermaid(): Promise<MermaidApi> {
mermaidPromise ??= import('mermaid').then(({ default: mermaid }) => {
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
suppressErrorRendering: true,
})
return mermaid
})
return mermaidPromise
}
function enqueueMermaidRender<T>(task: () => Promise<T>): Promise<T> {
const render = mermaidRenderQueue.then(task, task)
mermaidRenderQueue = render.then(
() => undefined,
() => undefined,
)
return render
}
function getTextContent(node: ReactNode): string {
return Children.toArray(node)
.map((child) => {
if (typeof child === 'string' || typeof child === 'number') {
return String(child)
}
if (isValidElement(child)) {
return getTextContent((child as ReactElement<{ children?: ReactNode }>).props.children)
}
return ''
})
.join('')
}
function CodeBlock({ children, mermaidError }: { children: ReactNode; mermaidError?: boolean }) {
return (
<div
className="my-4 rounded-lg border border-border/60 bg-secondary/30"
data-mermaid-error={mermaidError || undefined}
>
<div className="max-w-full overflow-x-auto rounded-lg bg-background px-4 py-3">
<pre className="m-0 min-w-max bg-transparent p-0 text-[13px] leading-6">{children}</pre>
</div>
</div>
)
}
interface MermaidBlockProps {
children: ReactNode
}
const MermaidBlock = memo(function MermaidBlock({ children }: MermaidBlockProps) {
const source = useMemo(() => getTextContent(children), [children])
const renderId = useRef(`mermaid-${++mermaidBlockSequence}`).current
const diagramRef = useRef<HTMLDivElement>(null)
const [svg, setSvg] = useState<string | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
let mounted = true
setSvg(null)
setFailed(false)
enqueueMermaidRender(async () => {
const mermaid = await loadMermaid()
return mermaid.render(renderId, source)
})
.then(({ svg: renderedSvg }) => {
if (mounted) {
setSvg(renderedSvg)
}
})
.catch(() => {
if (mounted) {
setSvg(null)
setFailed(true)
}
})
return () => {
mounted = false
}
}, [renderId, source])
useEffect(() => {
if (svg && diagramRef.current) {
diagramRef.current.innerHTML = svg
}
}, [svg])
if (!svg) {
return <CodeBlock mermaidError={failed}>{children}</CodeBlock>
}
return (
<div className="my-4 overflow-x-auto rounded-lg border border-border/60 bg-secondary/30" data-mermaid-diagram>
<div ref={diagramRef} className="min-w-0 bg-background px-4 py-3 [&_svg]:h-auto [&_svg]:max-w-full" />
</div>
)
})
interface MarkdownRendererProps {
content: string
className?: string
@ -112,13 +234,18 @@ function MarkdownRendererComponent({ content, className, onLinkClick }: Markdown
{children}
</li>
),
pre: ({ children }) => (
<div className="my-4 rounded-lg border border-border/60 bg-secondary/30">
<div className="max-w-full overflow-x-auto rounded-lg bg-background px-4 py-3">
<pre className="m-0 min-w-max bg-transparent p-0 text-[13px] leading-6">{children}</pre>
</div>
</div>
),
pre: ({ children }) => {
const codeChild = Children.toArray(children).find(isValidElement) as
| ReactElement<{ className?: string; children?: ReactNode }>
| undefined
const codeClassName = codeChild?.props.className
if (codeClassName?.split(/\s+/).includes('language-mermaid')) {
return <MermaidBlock>{codeChild}</MermaidBlock>
}
return <CodeBlock>{children}</CodeBlock>
},
code: ({ className: codeClassName, children, ...props }) => {
const isInline = !codeClassName?.includes('language-')