feat(sidebar): render mermaid diagrams in desktop PR comments (#5581)

Render Mermaid code fences in full desktop PR comment markdown while keeping compact sidebar previews bounded and source-only.
This commit is contained in:
gsxdsm 2026-06-18 09:18:24 +08:00 committed by GitHub
parent c2acb3c11b
commit 0eec0ad74a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 157 additions and 11 deletions

View File

@ -107,6 +107,83 @@ describe('CommentMarkdown', () => {
})
})
it('strips single-line and multi-line HTML comments', () => {
const markup = renderToStaticMarkup(
<CommentMarkdown
variant="document"
content={'before <!-- secret\nmulti-line\nnote --> after'}
/>
)
expect(markup).not.toContain('secret')
expect(markup).not.toContain('multi-line')
expect(markup).toContain('before')
expect(markup).toContain('after')
})
it('renders <details>/<summary> as a disclosure section', () => {
const markup = renderToStaticMarkup(
<CommentMarkdown
variant="document"
content={'<details><summary>Show more</summary>\n\nhidden body\n\n</details>'}
/>
)
expect(markup).toContain('<details')
expect(markup).toContain('<summary>Show more</summary>')
expect(markup).toContain('hidden body')
})
it('renders markdown blockquotes', () => {
const markup = renderToStaticMarkup(
<CommentMarkdown variant="document" content="> quoted text" />
)
expect(markup).toContain('<blockquote')
expect(markup).toContain('quoted text')
})
it('renders raw HTML blockquotes', () => {
const markup = renderToStaticMarkup(
<CommentMarkdown variant="document" content="<blockquote>html quote</blockquote>" />
)
expect(markup).toContain('<blockquote')
expect(markup).toContain('html quote')
})
it('renders GFM tables', () => {
const markup = renderToStaticMarkup(
<CommentMarkdown variant="document" content={'| a | b |\n|---|---|\n| 1 | 2 |'} />
)
expect(markup).toContain('<table')
expect(markup).toContain('<th>a</th>')
expect(markup).toContain('<td>1</td>')
})
it('renders mermaid code fences as a mermaid container instead of a pre block', () => {
const markup = renderToStaticMarkup(
<CommentMarkdown variant="document" content={'```mermaid\ngraph TD; A-->B;\n```'} />
)
expect(markup).toContain('mermaid-block')
expect(markup).toContain('overflow-x-auto')
expect(markup).toContain('[&amp;_.mermaid-block_pre]:max-h-80')
expect(markup).not.toContain('<pre')
})
it('keeps compact mermaid fences as bounded source blocks', () => {
const markup = renderToStaticMarkup(
<CommentMarkdown content={'```mermaid\ngraph TD; A-->B;\n```'} />
)
expect(markup).toContain('<pre')
expect(markup).toContain('max-h-32')
expect(markup).toContain('overflow-x-auto')
expect(markup).not.toContain('mermaid-block')
})
it('contains long PR body markdown inside its available width', () => {
const markup = renderToStaticMarkup(
<CommentMarkdown

View File

@ -6,6 +6,7 @@ import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import type { Components } from 'react-markdown'
import { cn } from '@/lib/utils'
import { isMermaidFence, isMermaidPre, renderMermaidFence } from './comment-mermaid-fence'
type MarkdownPlugins = NonNullable<React.ComponentProps<typeof Markdown>['rehypePlugins']>
type UrlTransform = NonNullable<React.ComponentProps<typeof Markdown>['urlTransform']>
@ -139,12 +140,14 @@ const compactComponents: Components = {
// the pill background/padding when code is inside a <pre>. This is
// more reliable than checking `className` — which is only set when
// the fenced block specifies a language (```js), not for bare ```.
// Why: compact comment previews live in dense cards; keep diagram fences as
// bounded source blocks so async SVG renders do not reshape sidebar lists.
code: ({ children }) => (
<code className="rounded bg-accent px-1 py-px text-[10px] font-mono [overflow-wrap:anywhere]">
{children}
</code>
),
// Compact pre blocks — no syntax highlighting needed for short comments
// Compact pre blocks — no syntax highlighting needed for short comments.
pre: ({ children }) => (
<pre className="my-1 max-h-32 max-w-full overflow-x-auto rounded bg-accent p-1.5 text-[10px] font-mono">
{children}
@ -242,16 +245,26 @@ const documentComponents: Components = {
{children}
</a>
),
code: ({ children }) => (
<code className="rounded bg-accent px-1.5 py-0.5 font-mono text-[0.92em] [overflow-wrap:anywhere]">
{children}
</code>
),
pre: ({ children }) => (
<pre className="my-3 max-h-80 max-w-full overflow-x-auto rounded-md bg-accent p-3 font-mono text-[12px]">
{children}
</pre>
),
code: ({ className, children }) =>
isMermaidFence(className) ? (
renderMermaidFence(
children,
'my-3 min-w-0 max-w-full overflow-x-auto rounded-md border border-border/60 p-3 [&_.mermaid-block]:min-w-0 [&_.mermaid-block_pre]:my-0 [&_.mermaid-block_pre]:max-h-80 [&_.mermaid-block_pre]:max-w-full [&_.mermaid-block_pre]:overflow-x-auto [&_.mermaid-block_pre]:rounded-md [&_.mermaid-block_pre]:bg-accent [&_.mermaid-block_pre]:p-3 [&_.mermaid-block_pre]:font-mono [&_.mermaid-block_pre]:text-[12px]'
)
) : (
<code className="rounded bg-accent px-1.5 py-0.5 font-mono text-[0.92em] [overflow-wrap:anywhere]">
{children}
</code>
),
// Mermaid fences render a <div>, which is invalid inside <pre>, so unwrap them.
pre: ({ children }) =>
isMermaidPre(children) ? (
<>{children}</>
) : (
<pre className="my-3 max-h-80 max-w-full overflow-x-auto rounded-md bg-accent p-3 font-mono text-[12px]">
{children}
</pre>
),
ul: ({ children }) => <ul className="my-2 ml-5 list-disc space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="my-2 ml-5 list-decimal space-y-1">{children}</ol>,
li: ({ children }) => (

View File

@ -0,0 +1,28 @@
import React from 'react'
import MermaidBlock from '@/components/editor/MermaidBlock'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
// Why: comment markdown components are module-level constants without access to
// the live theme, so this wrapper resolves dark mode from the app store (same
// logic the editor uses) and reuses the editor's MermaidBlock renderer. Mermaid
// HTML labels are disabled because MermaidBlock sanitizes the SVG, and sanitized
// foreignObject labels disappear on some platforms.
export default function CommentMermaidBlock({
content,
className
}: {
content: string
className?: string
}): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const isDark =
settings?.theme === 'dark' ||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
return (
<div className={cn(className)}>
<MermaidBlock content={content} isDark={isDark} htmlLabels={false} />
</div>
)
}

View File

@ -0,0 +1,28 @@
import React from 'react'
import CommentMermaidBlock from './CommentMermaidBlock'
// Why: react-markdown sets className="language-mermaid" on the <code> inside a
// fenced ```mermaid block. Detecting it lets us render a real diagram instead of
// the raw source, matching the editor's markdown preview.
export function isMermaidFence(className: string | undefined): boolean {
return /\blanguage-mermaid\b/.test(className ?? '')
}
export function renderMermaidFence(
children: React.ReactNode,
className?: string
): React.JSX.Element {
return <CommentMermaidBlock content={String(children).trimEnd()} className={className} />
}
// Why: MermaidBlock renders a <div> via innerHTML, which is invalid inside a
// <pre>. The <pre> renderer receives the inner <code> element (not the rendered
// diagram), so detect the mermaid fence from that child's className and unwrap.
export function isMermaidPre(children: React.ReactNode): boolean {
const child = React.Children.toArray(children)[0]
if (!React.isValidElement(child)) {
return false
}
const className = (child.props as { className?: string } | null)?.className
return isMermaidFence(className)
}