Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions components/markdown-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { cn } from '@/lib/utils'
import { markdownPreWithMermaid } from '@/components/mermaid-diagram'

/** One safe GFM renderer for full pages and narrow panels; raw HTML stays disabled. */
export function MarkdownContent({ children, className }: { children: string; className?: string }) {
return <div className={cn('markdown-body min-w-0', className)}>
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} components={{
table: ({ node: _node, ...props }) => <div className="markdown-table-scroll" role="region" aria-label="Markdown table" tabIndex={0}><table {...props} /></div>,
pre: markdownPreWithMermaid,
}}>{children}</ReactMarkdown>
</div>
}
91 changes: 91 additions & 0 deletions components/mermaid-diagram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
'use client'

import { isValidElement, useEffect, useId, useState, type ReactNode } from 'react'
import { Code2, GitBranch } from 'lucide-react'
import { Button } from '@/components/ui/button'

/** Renders Mermaid source to sanitized SVG on the client, with a toggle back to the raw source for copying/editing. */
export function MermaidDiagram({ source }: { source: string }) {
const rawId = useId().replace(/[^a-zA-Z0-9]/g, '')
const [svg, setSvg] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [showSource, setShowSource] = useState(false)

useEffect(() => {
let cancelled = false
setSvg(null)
setError(null)
import('mermaid').then(async ({ default: mermaid }) => {
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'neutral' })
try {
const result = await mermaid.render(`mermaid-${rawId}`, source)
if (!cancelled) setSvg(result.svg)
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Invalid diagram syntax')
}
})
return () => {
cancelled = true
}
}, [rawId, source])

const failed = error !== null
const displaySource = showSource || failed || svg === null

return (
<div className="not-prose my-4 overflow-hidden rounded-lg border bg-card">
<div className="flex items-center justify-between border-b bg-muted/40 px-3 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Diagram</span>
{!failed && svg !== null && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 gap-1.5 px-2 text-xs"
onClick={() => setShowSource((s) => !s)}
>
{showSource ? <GitBranch className="h-3 w-3" /> : <Code2 className="h-3 w-3" />}
{showSource ? 'View diagram' : 'View source'}
</Button>
)}
</div>
<div className="p-3">
{failed && (
<p className="mb-2 text-xs text-destructive">Couldn&apos;t render diagram: {error}</p>
)}
{displaySource ? (
<pre className="overflow-x-auto text-xs"><code>{source}</code></pre>
) : (
<div className="overflow-x-auto [&_svg]:mx-auto" dangerouslySetInnerHTML={{ __html: svg }} />
)}
</div>
</div>
)
}

/** Extracts fenced-code text from a `pre > code` react-markdown element tree. */
function codeText(children: ReactNode): string {
if (Array.isArray(children)) return children.map(codeText).join('')
if (typeof children === 'string') return children
return ''
}

/**
* react-markdown `pre` override: swaps ```mermaid fences for a rendered diagram
* (with a source toggle) while leaving every other fenced/code block untouched.
*/
export function markdownPreWithMermaid({
children,
...props
}: { children?: ReactNode } & React.HTMLAttributes<HTMLPreElement>) {
const code = Array.isArray(children) ? children[0] : children
const codeClassName =
isValidElement<{ className?: string; children?: ReactNode }>(code) ? code.props.className : undefined

if (codeClassName?.split(/\s+/).includes('language-mermaid')) {
const source = codeText(code.props.children).replace(/\n$/, '')
return <MermaidDiagram source={source} />
}

return <pre {...props}>{children}</pre>
}
16 changes: 16 additions & 0 deletions components/rich-text-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
Table as TableIcon,
Undo,
Redo,
Workflow,
} from 'lucide-react'
import { cn } from '@/lib/utils'

Expand Down Expand Up @@ -142,6 +143,21 @@ function Toolbar({ editor }: { editor: Editor }) {
<ToolbarButton title="Code block" active={editor.isActive('codeBlock')} onClick={() => c().toggleCodeBlock().run()}>
<SquareCode className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
title="Mermaid diagram"
active={editor.isActive('codeBlock', { language: 'mermaid' })}
onClick={() =>
c()
.insertContent({
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: 'flowchart TD\n A[Start] --> B[End]' }],
})
.run()
}
>
<Workflow className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
title="Insert table"
active={editor.isActive('table')}
Expand Down
2 changes: 2 additions & 0 deletions components/wiki/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { remarkWikiAnchors, resolveWikiLink } from '@/lib/wiki/markdown'
import { markdownPreWithMermaid } from '@/components/mermaid-diagram'
export function WikiMarkdown({
body,
sourceUrl,
Expand Down Expand Up @@ -58,6 +59,7 @@ export function WikiMarkdown({
<table {...props} />
</div>
),
pre: markdownPreWithMermaid,
}}
>
{body}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"input-otp": "1.4.2",
"lucide-react": "^0.564.0",
"mcp-handler": "^1.1.0",
"mermaid": "^12.0.0",
"next": "16.2.0",
"next-auth": "5.0.0-beta.31",
"next-themes": "^0.4.6",
Expand Down
Loading
Loading