mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-24 00:55:35 +00:00
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:
parent
6a89832bb7
commit
342d59472d
5 changed files with 1141 additions and 10 deletions
67
web/e2e/markdown-mermaid.spec.ts
Normal file
67
web/e2e/markdown-mermaid.spec.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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
836
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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-')
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue