Files
agent-park/src/components/project/MarkdownContent.tsx
T

281 lines
8.0 KiB
TypeScript

import React, { type PropsWithChildren } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeSanitize from 'rehype-sanitize'
import type { Components } from 'react-markdown'
interface MarkdownContentProps {
content: string
className?: string
}
const BLOCK_LEVEL_TAGS = new Set([
'pre',
'table',
'blockquote',
'ul',
'ol',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'div',
'img',
'hr',
])
// Generate heading ID from text
function generateHeadingId(text: string): string {
return text
.toString()
.toLowerCase()
.trim()
.replace(/\s+/g, '-') // Replace spaces with -
.replace(/[^\w\-\u4e00-\u9fa5]+/g, '') // Remove non-word chars except Chinese
.replace(/\-\-+/g, '-') // Replace multiple - with single -
.replace(/^-+/, '') // Trim - from start
.replace(/-+$/, '') // Trim - from end
}
// Custom GitHub-style components
const components: Components = {
// Headings with anchor links
h1: ({ children, ...props }: PropsWithChildren<object>) => {
const id = generateHeadingId(typeof children === 'string' ? children : '')
return (
<h1
id={id}
className="border-b border-gray-300 dark:border-gray-700 pb-2 mb-4 text-2xl font-semibold scroll-mt-20"
{...props}
>
<a href={`#${id}`} className="group">
{children}
<span className="ml-2 opacity-0 group-hover:opacity-100 inline-block text-gray-400 no-underline">
#
</span>
</a>
</h1>
)
},
h2: ({ children, ...props }: PropsWithChildren<object>) => {
const id = generateHeadingId(typeof children === 'string' ? children : '')
return (
<h2
id={id}
className="border-b border-gray-300 dark:border-gray-700 pb-2 mb-3 mt-8 text-xl font-semibold scroll-mt-20"
{...props}
>
<a href={`#${id}`} className="group">
{children}
<span className="ml-2 opacity-0 group-hover:opacity-100 inline-block text-gray-400 no-underline">
#
</span>
</a>
</h2>
)
},
h3: ({ children, ...props }: PropsWithChildren<object>) => {
const id = generateHeadingId(typeof children === 'string' ? children : '')
return (
<h3 id={id} className="mb-3 mt-6 text-lg font-semibold scroll-mt-20" {...props}>
<a href={`#${id}`} className="group">
{children}
<span className="ml-2 opacity-0 group-hover:opacity-100 inline-block text-gray-400 no-underline">
#
</span>
</a>
</h3>
)
},
h4: ({ children, ...props }) => (
<h4 className="mb-2 mt-4 text-base font-semibold" {...props}>
{children}
</h4>
),
// Paragraphs - skip wrapping if contains block-level elements like pre
p: ({ children, node, ...props }) => {
// Check if the paragraph node contains block-level elements in its children
// This uses the AST node data from react-markdown
const hasBlockElement = node?.children?.some((child) => {
const tagName = 'tagName' in child ? child.tagName : undefined
// Check if any direct child is a block-level element
return typeof tagName === 'string' && BLOCK_LEVEL_TAGS.has(tagName)
})
if (hasBlockElement) {
return <>{children}</>
}
return (
<p className="mb-4 leading-7" {...props}>
{children}
</p>
)
},
// Lists
ul: ({ children, className, ...props }) => (
<ul
className={`mb-4 ml-6 list-disc space-y-2 ${className || ''}`}
{...props}
>
{children}
</ul>
),
ol: ({ children, className, ...props }) => (
<ol
className={`mb-4 ml-6 list-decimal space-y-2 ${className || ''}`}
{...props}
>
{children}
</ol>
),
li: ({ children, className, ...props }) => (
<li className={`mt-2 ${className || ''}`} {...props}>
{children}
</li>
),
// Code blocks (pre element wrapper)
pre: ({ children, ...props }) => {
return (
<pre className="bg-gray-900 dark:bg-gray-950 text-gray-100 p-4 rounded-lg overflow-x-auto my-4 border border-gray-700" {...props}>
{children}
</pre>
)
},
// Code elements (both inline and in code blocks)
code: ({ className, children, ...props }) => {
// If there's no language class and no newlines, treat as inline code.
const hasLanguageClass = className && typeof className === 'string' && className.startsWith('language-')
const childStr = String(children)
const hasNewlines = childStr.includes('\n')
const isInline = !hasLanguageClass && !hasNewlines
if (isInline) {
return (
<code
className="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-sm font-mono border border-gray-300 dark:border-gray-600"
{...props}
>
{children}
</code>
)
}
// For code blocks, just return the code element - the pre wrapper will be added by react-markdown
return (
<code className="text-sm font-mono" {...props}>
{children}
</code>
)
},
// Blockquotes
blockquote: ({ children, ...props }) => (
<blockquote
className="pl-4 border-l-4 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 italic my-4"
{...props}
>
{children}
</blockquote>
),
// Tables
table: ({ children, ...props }) => (
<div className="overflow-x-auto my-4">
<table className="min-w-full divide-y divide-gray-300 dark:divide-gray-700 border border-gray-300 dark:border-gray-600" {...props}>
{children}
</table>
</div>
),
thead: ({ children, ...props }) => (
<thead className="bg-gray-50 dark:bg-gray-800" {...props}>
{children}
</thead>
),
th: ({ children, ...props }) => (
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-300 dark:border-gray-700" {...props}>
{children}
</th>
),
td: ({ children, ...props }) => (
<td className="px-4 py-2 text-sm border-b border-gray-300 dark:border-gray-700" {...props}>
{children}
</td>
),
// Links
a: ({ children, href, ...props }) => (
<a
href={href}
className="text-blue-600 dark:text-blue-400 hover:underline"
target={href?.startsWith('http') ? '_blank' : undefined}
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
{...props}
>
{children}
</a>
),
// Images - wrapped in container for size control
img: ({ src, alt, ...props }) => (
<div className="my-4 flex justify-center">
<div className="max-w-md w-full">
{/* External markdown images use arbitrary remote hosts; Next/Image is not suitable here. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={alt}
className="rounded-lg border border-gray-300 dark:border-gray-600 w-full h-auto object-contain"
loading="lazy"
{...props}
/>
</div>
</div>
),
// Horizontal rule
hr: ({ ...props }) => (
<hr className="my-8 border-t border-gray-300 dark:border-gray-700" {...props} />
),
// Task lists
input: ({ type, checked, ...props }) =>
type === 'checkbox' ? (
<input
type="checkbox"
checked={checked}
readOnly
className="mr-2 h-4 w-4 rounded border-gray-300"
name="task-item"
{...props}
/>
) : null,
}
export function MarkdownContent({ content, className = '' }: MarkdownContentProps) {
if (!content || content.trim() === '') {
return (
<p className="text-gray-500 dark:text-gray-400 italic">No content available.</p>
)
}
return (
<article className={`markdown-body text-gray-900 dark:text-gray-100 ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize]}
components={components}
>
{content}
</ReactMarkdown>
</article>
)
}