(null)
@@ -143,13 +145,13 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
[streaming, highlighting, streamedBody, trimmed, lang, loaded],
)
const [copied, setCopied] = useState(false)
+ const [showSource, setShowSource] = useState(false)
+ const previewAvailable = preview !== undefined && streaming !== true
+ const showingPreview = previewAvailable && !showSource
const onCopy = useCallback(() => {
if (copied) return
- /* v8 ignore next -- both arms always mount a ; trimmed is the
- typed fallback if the DOM shape ever diverges. */
- const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
- void writeClipboard(text).then((ok) => {
+ void writeClipboard(trimmed).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
@@ -181,13 +183,18 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
{lang ?? ''}
+ {previewAvailable && (
+
+ )}
- {body}
+ {showingPreview ? preview.content : body}
)
}
diff --git a/packages/client/ui-primitives/src/markdown/MermaidPreview.module.css b/packages/client/ui-primitives/src/markdown/MermaidPreview.module.css
new file mode 100644
index 0000000000..bd24e36251
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/MermaidPreview.module.css
@@ -0,0 +1,26 @@
+.canvas {
+ overflow: auto;
+ padding: 16px;
+ background: var(--dsw-static-neutral-00);
+ border-radius: 0 0 12px 12px;
+}
+
+.diagram {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ margin: 0 auto;
+}
+
+.status {
+ padding: 16px;
+ color: var(--dsw-alias-label-secondary);
+}
+
+.staging {
+ position: fixed;
+ top: 0;
+ left: -100000px;
+ visibility: hidden;
+ pointer-events: none;
+}
diff --git a/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx b/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx
new file mode 100644
index 0000000000..b59a09a48c
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx
@@ -0,0 +1,42 @@
+/** Read-only diagram preview with source fallback and per-source async ownership. */
+
+import { useEffect, useState } from 'react'
+import { renderMermaid } from './mermaid.ts'
+import css from './MermaidPreview.module.css'
+
+/** Localized preview states; the diagram source remains verbatim. */
+export interface MermaidPreviewLabels {
+ diagram: string
+ loading: string
+ error: string
+}
+
+type Result = { code: string; src: string } | { code: string; error: true }
+
+/**
+ * Display a complete Mermaid document on a light diagram canvas, or its source if rendering fails.
+ * @param props - Source and complete localized labels. Changing source discards the previous result.
+ * @returns A loading status, an inert SVG image, or an error with the original source.
+ */
+export function MermaidPreview({ code, labels }: { code: string; labels: MermaidPreviewLabels }) {
+ const [result, setResult] = useState(null)
+ useEffect(() => {
+ const controller = new AbortController()
+ void renderMermaid(code, controller.signal).then(
+ (src) => { if (!controller.signal.aborted) setResult({ code, src }) },
+ () => { if (!controller.signal.aborted) setResult({ code, error: true }) },
+ )
+ return () => { controller.abort() }
+ }, [code])
+
+ if (result?.code !== code) return {labels.loading}
+ if ('error' in result) {
+ return (
+
+
{labels.error}
+
{code}
+
+ )
+ }
+ return 
+}
diff --git a/packages/client/ui-primitives/src/markdown/mermaid.ts b/packages/client/ui-primitives/src/markdown/mermaid.ts
new file mode 100644
index 0000000000..4db2a64b6c
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/mermaid.ts
@@ -0,0 +1,59 @@
+/** Lazy Mermaid runtime; each render owns and removes its temporary measurement DOM. */
+
+import type { Mermaid } from 'mermaid'
+import clsx from 'clsx'
+import css from './MermaidPreview.module.css'
+
+let runtime: Promise | undefined
+let nextDiagramId = 0
+
+function loadMermaid(): Promise {
+ runtime ??= import('mermaid').then(({ default: mermaid }) => {
+ mermaid.initialize({
+ startOnLoad: false,
+ securityLevel: 'strict',
+ suppressErrorRendering: true,
+ theme: 'neutral',
+ htmlLabels: false,
+ secure: [
+ 'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges',
+ 'suppressErrorRendering', 'theme', 'themeVariables', 'themeCSS', 'htmlLabels', 'flowchart',
+ ],
+ })
+ return mermaid
+ }).catch((error: unknown) => {
+ runtime = undefined
+ throw error
+ })
+ return runtime
+}
+
+/**
+ * Render untrusted diagram source as an SVG image URL, without installing SVG or link handlers in the UI.
+ * @param code - Complete Mermaid source.
+ * @param signal - Cancels work waiting for the runtime; an active Mermaid render finishes before cleanup.
+ * @returns An SVG data URL. Import, parse, rendering, and cancellation failures reject.
+ */
+export async function renderMermaid(code: string, signal: AbortSignal): Promise {
+ const mermaid = await loadMermaid()
+ signal.throwIfAborted()
+ const stage = document.createElement('div')
+ stage.className = clsx(css.staging)
+ stage.setAttribute('aria-hidden', 'true')
+ document.body.append(stage)
+ try {
+ // Mermaid's public render API serializes diagram work; the lower-level mermaidAPI does not.
+ const { svg } = await mermaid.render(`dsh-mermaid-${nextDiagramId++}`, code, stage)
+ const root = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement
+ const viewBox = root.getAttribute('viewBox')
+ if (viewBox !== null) {
+ // An SVG image needs intrinsic dimensions; Mermaid's percentage width is for inline SVG.
+ const [, , width, height] = viewBox.split(/\s+/) as [string, string, string, string]
+ root.setAttribute('width', width)
+ root.setAttribute('height', height)
+ }
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(root))}`
+ } finally {
+ stage.remove()
+ }
+}
diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx
index 4d7805553b..d946c06893 100644
--- a/packages/client/ui-primitives/src/markdown/render.tsx
+++ b/packages/client/ui-primitives/src/markdown/render.tsx
@@ -23,6 +23,8 @@ import type * as Md from 'mdast'
import type {} from 'mdast-util-math'
import { normalizeUri } from 'micromark-util-sanitize-uri'
import { CodeBlock } from './CodeBlock.tsx'
+import { MermaidPreview } from './MermaidPreview.tsx'
+import type { MermaidPreviewLabels } from './MermaidPreview.tsx'
import { renderTexToReact } from './katex.tsx'
import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx'
import type { PositionedBlock } from './incremental.ts'
@@ -40,6 +42,8 @@ export interface MarkdownCodeLabels {
export interface MarkdownLabels {
code: MarkdownCodeLabels
footnotes: string
+ /** Opt into settled Mermaid fence previews by supplying their complete localized chrome. */
+ mermaid?: MermaidPreviewLabels & { preview: string; source: string }
}
function sanitizeUrl(url: string): string {
@@ -394,6 +398,11 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
streaming={context.streaming}
copyLabel={context.labels.code.copyLabel}
copiedLabel={context.labels.code.copiedLabel}
+ preview={lang === 'mermaid' && !context.streaming && context.labels.mermaid !== undefined ? {
+ content: ,
+ previewLabel: context.labels.mermaid.preview,
+ sourceLabel: context.labels.mermaid.source,
+ } : undefined}
/>
)
}
diff --git a/packages/client/ui-primitives/tests/code-block.client.spec.tsx b/packages/client/ui-primitives/tests/code-block.client.spec.tsx
index 4413cf46d2..83c016c8f0 100644
--- a/packages/client/ui-primitives/tests/code-block.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/code-block.client.spec.tsx
@@ -122,7 +122,7 @@ describe('CodeBlock', () => {
expect(view.getByText('plain text')).toBeTruthy()
})
- it('shows the language banner and copies the pre textContent', async () => {
+ it('shows the language banner and copies the displayed source text', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
diff --git a/packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx b/packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx
new file mode 100644
index 0000000000..4a716c4886
--- /dev/null
+++ b/packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx
@@ -0,0 +1,107 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { MermaidPreview } from '../src/markdown/MermaidPreview.tsx'
+import { renderMermaid } from '../src/markdown/mermaid.ts'
+import { MarkdownText } from '../src/markdown/MarkdownText.tsx'
+import { markdownLabels } from './labels.client.ts'
+
+vi.mock('../src/markdown/mermaid.ts', () => ({ renderMermaid: vi.fn() }))
+
+const labels = {
+ diagram: 'Mermaid diagram', loading: 'Rendering diagram…', error: 'Unable to render this diagram.',
+ preview: 'Preview', source: 'Source',
+}
+const source = 'flowchart LR\n A[Input] --> B[Preview]'
+const imageUrl = 'data:image/svg+xml;charset=utf-8,%3Csvg%2F%3E'
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals() })
+beforeEach(() => { vi.resetAllMocks() })
+
+describe('MermaidPreview', () => {
+ it('shows loading until rendering completes, then displays an image without inserting SVG', async () => {
+ const pending = Promise.withResolvers()
+ vi.mocked(renderMermaid).mockReturnValue(pending.promise)
+ const view = render()
+ expect(screen.getByRole('status').textContent).toBe(labels.loading)
+ expect(screen.queryByRole('img')).toBeNull()
+ await act(async () => { pending.resolve(imageUrl) })
+ expect(screen.getByRole('img', { name: labels.diagram }).getAttribute('src')).toBe(imageUrl)
+ expect(view.container.querySelector('svg')).toBeNull()
+ expect(screen.queryByRole('status')).toBeNull()
+ })
+
+ it('retains invalid source and recovers when the source changes', async () => {
+ vi.mocked(renderMermaid).mockRejectedValueOnce(new Error('Parse error'))
+ const view = render()
+ expect((await screen.findByText(labels.error)).getAttribute('role')).toBe('status')
+ expect(view.container.querySelector('pre code')?.textContent).toBe('invalid')
+ const next = Promise.withResolvers()
+ vi.mocked(renderMermaid).mockReturnValue(next.promise)
+ view.rerender()
+ expect(screen.getByRole('status').textContent).toBe(labels.loading)
+ expect(view.container.querySelector('pre')).toBeNull()
+ await act(async () => { next.resolve(imageUrl) })
+ expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl)
+ })
+
+ it.each(['resolve', 'reject'] as const)('ignores a stale %s after a newer source finishes', async (outcome) => {
+ const old = Promise.withResolvers()
+ const next = Promise.withResolvers()
+ vi.mocked(renderMermaid).mockReturnValueOnce(old.promise).mockReturnValueOnce(next.promise)
+ const view = render()
+ const oldSignal = vi.mocked(renderMermaid).mock.calls[0]![1]
+ view.rerender()
+ expect(oldSignal.aborted).toBe(true)
+ await act(async () => { next.resolve(imageUrl) })
+ await act(async () => {
+ if (outcome === 'resolve') old.resolve('obsolete-image')
+ else old.reject(new Error('obsolete-error'))
+ })
+ expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl)
+ expect(screen.queryByRole('status')).toBeNull()
+ })
+
+ it('cancels the owner when unmounted while loading', async () => {
+ const pending = Promise.withResolvers()
+ vi.mocked(renderMermaid).mockReturnValue(pending.promise)
+ const view = render()
+ const signal = vi.mocked(renderMermaid).mock.calls[0]![1]
+ view.unmount()
+ expect(signal.aborted).toBe(true)
+ await act(async () => { pending.reject(new Error('cancelled')) })
+ expect(screen.queryByRole('img')).toBeNull()
+ })
+})
+
+describe('Markdown Mermaid fences', () => {
+ it('keeps streaming source literal, then offers preview, source and source copying', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ vi.stubGlobal('navigator', Object.create(navigator, { clipboard: { value: { writeText } } }))
+ vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
+ const props = { text: `\`\`\`mermaid\n${source}\n\`\`\``, labels: { ...markdownLabels, mermaid: labels } }
+ const view = render()
+ expect(view.container.querySelector('pre code')?.textContent).toBe(source)
+ expect(renderMermaid).not.toHaveBeenCalled()
+ expect(screen.queryByRole('button', { name: labels.source })).toBeNull()
+ view.rerender()
+ await screen.findByRole('img', { name: labels.diagram })
+ fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel }))
+ await screen.findByRole('button', { name: markdownLabels.code.copiedLabel })
+ expect(writeText).toHaveBeenCalledWith(source)
+ fireEvent.click(screen.getByRole('button', { name: labels.source }))
+ expect(view.container.querySelector('pre code')?.textContent).toBe(source)
+ expect(screen.queryByRole('img')).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: labels.preview }))
+ await screen.findByRole('img', { name: labels.diagram })
+ })
+
+ it('leaves other languages and consumers without preview labels as code', () => {
+ const view = render()
+ expect(view.container.querySelector('pre code')?.textContent).toBe(source)
+ view.rerender()
+ expect(view.container.querySelector('pre code')?.textContent).toBe('flowchart LR')
+ expect(renderMermaid).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts b/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts
new file mode 100644
index 0000000000..8b79a42b9a
--- /dev/null
+++ b/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts
@@ -0,0 +1,83 @@
+// @vitest-environment jsdom
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const initialize = vi.fn()
+const renderDiagram = vi.fn()
+
+beforeEach(() => {
+ vi.resetModules()
+ vi.resetAllMocks()
+ vi.doMock('mermaid', () => ({ default: { initialize, render: renderDiagram } }))
+})
+afterEach(() => { vi.doUnmock('mermaid') })
+
+describe('Mermaid runtime', () => {
+ it('shares lazy initialization and removes each measurement container after rendering', async () => {
+ const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+ expect(initialize).not.toHaveBeenCalled()
+ const stages: HTMLElement[] = []
+ const ids: string[] = []
+ renderDiagram.mockImplementation(async (id: string, code: string, stage: HTMLElement) => {
+ expect(stage.isConnected).toBe(true)
+ expect(stage.getAttribute('aria-hidden')).toBe('true')
+ stages.push(stage)
+ ids.push(id)
+ return { svg: `` }
+ })
+ const results = await Promise.all([
+ renderMermaid('中文', new AbortController().signal),
+ renderMermaid('second', new AbortController().signal),
+ ])
+ expect(initialize).toHaveBeenCalledOnce()
+ expect(initialize).toHaveBeenCalledWith(expect.objectContaining({
+ startOnLoad: false, securityLevel: 'strict', suppressErrorRendering: true, htmlLabels: false,
+ secure: [
+ 'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges',
+ 'suppressErrorRendering', 'theme', 'themeVariables', 'themeCSS', 'htmlLabels', 'flowchart',
+ ],
+ }))
+ expect(results.map(url => decodeURIComponent(url.split(',')[1]!))).toEqual(['', ''])
+ expect(new Set(ids).size).toBe(2)
+ expect(stages.every(stage => !stage.isConnected)).toBe(true)
+ })
+
+ it('removes measurement DOM even when Mermaid rejects', async () => {
+ const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+ let stage: HTMLElement | undefined
+ const error = new Error('bad diagram')
+ renderDiagram.mockImplementation(async (_id: string, _code: string, target: HTMLElement) => {
+ stage = target
+ target.innerHTML = ''
+ throw error
+ })
+ await expect(renderMermaid('bad', new AbortController().signal)).rejects.toBe(error)
+ expect(stage?.isConnected).toBe(false)
+ })
+
+ it('preserves intrinsic diagram size instead of stretching percentage-width SVG images', async () => {
+ const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+ renderDiagram.mockResolvedValue({ svg: '' })
+ const url = await renderMermaid('flowchart LR', new AbortController().signal)
+ const svg = new DOMParser().parseFromString(decodeURIComponent(url.split(',')[1]!), 'image/svg+xml').documentElement
+ expect(svg.getAttribute('width')).toBe('420')
+ expect(svg.getAttribute('height')).toBe('180')
+ })
+
+ it('does not start cancelled work after loading the runtime', async () => {
+ const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+ const controller = new AbortController()
+ controller.abort()
+ await expect(renderMermaid('unused', controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
+ expect(renderDiagram).not.toHaveBeenCalled()
+ })
+
+ it('allows another attempt after runtime initialization fails', async () => {
+ const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+ initialize.mockImplementationOnce(() => { throw new Error('initialization failed') })
+ await expect(renderMermaid('first', new AbortController().signal)).rejects.toThrow('initialization failed')
+ renderDiagram.mockResolvedValue({ svg: '' })
+ await expect(renderMermaid('retry', new AbortController().signal)).resolves.toContain('data:image/svg+xml')
+ expect(initialize).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bcea481379..d1c1098a48 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3323,6 +3323,9 @@ importers:
mdast-util-math:
specifier: ^3.0.0
version: 3.0.0
+ mermaid:
+ specifier: 11.16.0
+ version: 11.16.0
micromark-core-commonmark:
specifier: ^2.0.3
version: 2.0.3
diff --git a/tsconfig.host.json b/tsconfig.host.json
index c5a3f118b3..e7abea1600 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -67,6 +67,7 @@
"apps/web/tests/math-rendering.e2e.ts",
"apps/web/tests/markdown-cjk-strong.e2e.ts",
"apps/web/tests/markdown-inline-code-links.e2e.ts",
+ "apps/web/tests/markdown-mermaid.e2e.ts",
"apps/web/tests/clickable-links-gallery.e2e.ts",
"apps/web/tests/queue-actions.e2e.ts",
"apps/web/tests/queue-image.e2e.ts",