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:
parent
c2acb3c11b
commit
0eec0ad74a
|
|
@ -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('[&_.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
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue