281 lines
8.0 KiB
TypeScript
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>
|
|
)
|
|
}
|