248 lines
7.9 KiB
TypeScript
248 lines
7.9 KiB
TypeScript
'use client'
|
|
|
|
import ReactMarkdown from 'react-markdown'
|
|
import remarkGfm from 'remark-gfm'
|
|
import rehypeSanitize from 'rehype-sanitize'
|
|
import rehypeRaw from 'rehype-raw'
|
|
import { OptimizedImage } from './OptimizedImage'
|
|
import { CodeBlock } from './code-block'
|
|
import { useLocale } from 'next-intl'
|
|
import { Link } from '@/i18n/navigation'
|
|
|
|
interface MarkdownRendererProps {
|
|
content: string
|
|
className?: string
|
|
}
|
|
|
|
export default function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
|
|
const locale = useLocale()
|
|
return (
|
|
<div className={`prose prose-invert prose-zinc max-w-none ${className}`}>
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
rehypePlugins={[
|
|
rehypeRaw,
|
|
[
|
|
rehypeSanitize,
|
|
{
|
|
tagNames: [
|
|
'p',
|
|
'a',
|
|
'img',
|
|
'code',
|
|
'pre',
|
|
'h1',
|
|
'h2',
|
|
'h3',
|
|
'h4',
|
|
'h5',
|
|
'h6',
|
|
'ul',
|
|
'ol',
|
|
'li',
|
|
'blockquote',
|
|
'table',
|
|
'thead',
|
|
'tbody',
|
|
'tr',
|
|
'th',
|
|
'td',
|
|
'strong',
|
|
'em',
|
|
'del',
|
|
'br',
|
|
'hr',
|
|
'div',
|
|
'span',
|
|
],
|
|
attributes: {
|
|
a: ['href', 'rel', 'target'],
|
|
img: ['src', 'alt', 'title', 'width', 'height'],
|
|
code: ['className'],
|
|
'*': ['className', 'id'],
|
|
},
|
|
},
|
|
],
|
|
]}
|
|
components={{
|
|
img: ({ node, src, alt, title, ...props }) => {
|
|
if (!src || typeof src !== 'string') return null
|
|
|
|
const isExternal = src.startsWith('http://') || src.startsWith('https://')
|
|
|
|
if (isExternal) {
|
|
return (
|
|
<img
|
|
src={src}
|
|
alt={alt || ''}
|
|
title={title}
|
|
className="rounded-lg border border-zinc-800"
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
// Ensure absolute path for Next Image
|
|
const absoluteSrc = src.startsWith('/') ? src : `/${src}`
|
|
|
|
const titleStr = typeof title === 'string' ? title : ''
|
|
const [altText, caption] = titleStr?.includes('|')
|
|
? titleStr.split('|').map(s => s.trim())
|
|
: [alt, undefined]
|
|
|
|
const url = new URL(absoluteSrc, 'http://localhost')
|
|
const width = url.searchParams.get('w') ? parseInt(url.searchParams.get('w')!) : null
|
|
const height = url.searchParams.get('h') ? parseInt(url.searchParams.get('h')!) : null
|
|
const cleanSrc = absoluteSrc.split('?')[0]
|
|
|
|
const imageProps = {
|
|
src: cleanSrc,
|
|
alt: altText || alt || '',
|
|
caption: caption,
|
|
...(width && { width }),
|
|
...(height && { height }),
|
|
}
|
|
|
|
return <OptimizedImage {...imageProps} />
|
|
},
|
|
code: ({ node, className, children, ...props }) => {
|
|
const inline = !className && typeof children === 'string' && !children.includes('\n')
|
|
const match = /language-(\w+)/.exec(className || '')
|
|
const language = match ? match[1] : ''
|
|
|
|
if (inline) {
|
|
return (
|
|
<code
|
|
className="rounded bg-zinc-900 px-1.5 py-0.5 text-sm text-emerald-400"
|
|
{...props}
|
|
>
|
|
{children}
|
|
</code>
|
|
)
|
|
}
|
|
|
|
return <CodeBlock code={String(children).replace(/\n$/, '')} language={language} />
|
|
},
|
|
a: ({ node, href, children, ...props }) => {
|
|
if (!href) return <a {...props}>{children}</a>
|
|
|
|
const isExternal = href.startsWith('http://') || href.startsWith('https://')
|
|
const isAnchor = href.startsWith('#')
|
|
|
|
if (isExternal) {
|
|
return (
|
|
<a
|
|
href={href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-emerald-400 hover:text-emerald-300 inline-flex items-center gap-1"
|
|
{...props}
|
|
>
|
|
{children}
|
|
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
|
/>
|
|
</svg>
|
|
</a>
|
|
)
|
|
}
|
|
|
|
if (isAnchor) {
|
|
return (
|
|
<a href={href} className="text-emerald-400 hover:text-emerald-300" {...props}>
|
|
{children}
|
|
</a>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Link href={href} className="text-emerald-400 hover:text-emerald-300" {...props}>
|
|
{children}
|
|
</Link>
|
|
)
|
|
},
|
|
h1: ({ node, children, ...props }) => {
|
|
const text = String(children)
|
|
const id = text
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/(^-|-$)/g, '')
|
|
return (
|
|
<h1 id={id} className="text-3xl font-bold text-zinc-100 mt-8 mb-4" {...props}>
|
|
{children}
|
|
</h1>
|
|
)
|
|
},
|
|
h2: ({ node, children, ...props }) => {
|
|
const text = String(children)
|
|
const id = text
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/(^-|-$)/g, '')
|
|
return (
|
|
<h2 id={id} className="text-2xl font-bold text-zinc-100 mt-6 mb-3" {...props}>
|
|
{children}
|
|
</h2>
|
|
)
|
|
},
|
|
h3: ({ node, children, ...props }) => {
|
|
const text = String(children)
|
|
const id = text
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/(^-|-$)/g, '')
|
|
return (
|
|
<h3 id={id} className="text-xl font-bold text-zinc-100 mt-4 mb-2" {...props}>
|
|
{children}
|
|
</h3>
|
|
)
|
|
},
|
|
ul: ({ node, children, ...props }) => (
|
|
<ul className="list-disc list-inside space-y-2 text-zinc-300" {...props}>
|
|
{children}
|
|
</ul>
|
|
),
|
|
ol: ({ node, children, ...props }) => (
|
|
<ol className="list-decimal list-inside space-y-2 text-zinc-300" {...props}>
|
|
{children}
|
|
</ol>
|
|
),
|
|
blockquote: ({ node, children, ...props }) => (
|
|
<blockquote
|
|
className="border-l-4 border-emerald-500 pl-4 italic text-zinc-400"
|
|
{...props}
|
|
>
|
|
{children}
|
|
</blockquote>
|
|
),
|
|
table: ({ node, children, ...props }) => (
|
|
<div className="overflow-x-auto my-6">
|
|
<table className="min-w-full border border-zinc-800" {...props}>
|
|
{children}
|
|
</table>
|
|
</div>
|
|
),
|
|
th: ({ node, children, ...props }) => (
|
|
<th
|
|
className="bg-zinc-900 px-4 py-2 text-left font-bold text-zinc-100 border border-zinc-800"
|
|
{...props}
|
|
>
|
|
{children}
|
|
</th>
|
|
),
|
|
td: ({ node, children, ...props }) => (
|
|
<td className="px-4 py-2 text-zinc-300 border border-zinc-800" {...props}>
|
|
{children}
|
|
</td>
|
|
),
|
|
}}
|
|
>
|
|
{content}
|
|
</ReactMarkdown>
|
|
</div>
|
|
)
|
|
}
|