/ src / components / shared / markdown-body.tsx
markdown-body.tsx
  1  'use client'
  2  
  3  import type { ReactNode } from 'react'
  4  import ReactMarkdown from 'react-markdown'
  5  import remarkGfm from 'remark-gfm'
  6  import rehypeHighlight from 'rehype-highlight'
  7  import { CodeBlock } from '@/components/chat/code-block'
  8  
  9  export interface MarkdownBodyProps {
 10    text: string
 11    /** Custom link renderer — return non-null to override default handling */
 12    renderLink?: (href: string, children: ReactNode) => ReactNode | null
 13    /** Custom inline code renderer — return non-null to override default */
 14    renderInlineCode?: (text: string, children: ReactNode, className?: string) => ReactNode | null
 15    /** Custom paragraph renderer — return non-null to override default */
 16    renderParagraph?: (node: unknown, children: ReactNode) => ReactNode | null
 17    /** Media URLs to skip (already rendered elsewhere, e.g. tool events) */
 18    skipMediaUrls?: Set<string>
 19  }
 20  
 21  export function MarkdownBody({
 22    text,
 23    renderLink,
 24    renderInlineCode,
 25    renderParagraph,
 26    skipMediaUrls,
 27  }: MarkdownBodyProps) {
 28    return (
 29      <ReactMarkdown
 30        remarkPlugins={[remarkGfm]}
 31        rehypePlugins={[rehypeHighlight]}
 32        components={{
 33          pre({ children }) {
 34            return <pre>{children}</pre>
 35          },
 36          ...(renderParagraph && {
 37            p({ node, children }: { node?: unknown; children?: ReactNode }) {
 38              const custom = renderParagraph(node, children)
 39              if (custom !== null) return <>{custom}</>
 40              return <p>{children}</p>
 41            },
 42          }),
 43          code({ className: cn, children }) {
 44            const isBlock = cn?.startsWith('language-') || cn?.startsWith('hljs')
 45            if (isBlock) return <CodeBlock className={cn}>{children}</CodeBlock>
 46            if (renderInlineCode) {
 47              const rawText = typeof children === 'string' ? children : ''
 48              const custom = renderInlineCode(rawText, children, cn)
 49              if (custom !== null) return <>{custom}</>
 50            }
 51            return <code className={cn}>{children}</code>
 52          },
 53          img({ src, alt }) {
 54            if (!src || typeof src !== 'string') return null
 55            if (skipMediaUrls?.has(src)) return null
 56            const isVideo = /\.(mp4|webm|mov|avi)$/i.test(src)
 57            if (isVideo) {
 58              return <video src={src} controls preload="none" className="max-w-full rounded-[10px] border border-white/10 my-2" />
 59            }
 60            return (
 61              <a href={src} download target="_blank" rel="noopener noreferrer" className="block my-2">
 62                {/* eslint-disable-next-line @next/next/no-img-element */}
 63                <img
 64                  src={src}
 65                  alt={alt || 'Image'}
 66                  loading="lazy"
 67                  className="max-w-full max-h-[400px] rounded-[10px] border border-white/[0.06] hover:border-white/25 transition-colors"
 68                  onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
 69                />
 70              </a>
 71            )
 72          },
 73          a({ href, children }) {
 74            if (!href) return <>{children}</>
 75            if (renderLink) {
 76              const custom = renderLink(href, children)
 77              if (custom !== null) return <>{custom}</>
 78            }
 79            // YouTube embed
 80            const ytMatch = href.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/)
 81            if (ytMatch) {
 82              return (
 83                <div className="my-2">
 84                  <iframe
 85                    src={`https://www.youtube-nocookie.com/embed/${ytMatch[1]}`}
 86                    className="w-full max-w-[480px] aspect-video rounded-[10px] border border-white/[0.06]"
 87                    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
 88                    allowFullScreen
 89                    title="YouTube video"
 90                  />
 91                </div>
 92              )
 93            }
 94            // Upload download links
 95            if (href.includes('/api/uploads/')) {
 96              const filename = href.split('/').pop() || 'file'
 97              return (
 98                <a href={href} download={filename} className="text-accent-bright hover:underline">
 99                  {children}
100                </a>
101              )
102            }
103            // Default external link
104            return (
105              <a href={href} target="_blank" rel="noopener noreferrer" className="text-accent-bright hover:underline">
106                {children}
107              </a>
108            )
109          },
110        }}
111      >
112        {text}
113      </ReactMarkdown>
114    )
115  }