feat: 添加项目详情 Markdown 渲染支持及项目添加脚本
- 新增 MarkdownContent 组件支持 Markdown 渲染 - 新增 scripts 目录下的项目添加工具脚本 - 更新项目详情页面支持 Markdown 内容显示 - 更新依赖包支持 Markdown 解析 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,7 @@ export default async function ProjectDetailPage({
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark font-body transition-colors duration-200">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="container mx-auto max-w-6xl px-4 sm:px-6 lg:px-8 py-12">
|
||||
{/* Back Button */}
|
||||
<div className="mb-8">
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
'use client'
|
||||
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { Components } from 'react-markdown'
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
// 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 }: any) => {
|
||||
const id = generateHeadingId(children?.toString() || '')
|
||||
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>
|
||||
)
|
||||
}) as any,
|
||||
h2: (({ children, ...props }: any) => {
|
||||
const id = generateHeadingId(children?.toString() || '')
|
||||
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>
|
||||
)
|
||||
}) as any,
|
||||
h3: (({ children, ...props }: any) => {
|
||||
const id = generateHeadingId(children?.toString() || '')
|
||||
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>
|
||||
)
|
||||
}) as any,
|
||||
h4: ({ children, ...props }) => (
|
||||
<h4 className="mb-2 mt-4 text-base font-semibold" {...props}>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
|
||||
// Paragraphs
|
||||
p: ({ children, ...props }) => (
|
||||
<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 with syntax highlighting
|
||||
code: ({ inline, className, children, ...props }: any) => {
|
||||
if (inline) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const language = className?.replace(/language-/, '') || 'text'
|
||||
|
||||
return (
|
||||
<SyntaxHighlighterWrapper language={language} code={String(children).trim()} />
|
||||
)
|
||||
},
|
||||
|
||||
// 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
|
||||
img: ({ src, alt, ...props }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="rounded-lg border border-gray-300 dark:border-gray-600 my-4 max-w-full h-auto"
|
||||
loading="lazy"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
|
||||
// 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"
|
||||
{...props}
|
||||
/>
|
||||
) : null,
|
||||
}
|
||||
|
||||
// Syntax highlighter wrapper (will use Shiki for server-side, Prism for client)
|
||||
function SyntaxHighlighterWrapper({ language, code }: { language: string; code: string }) {
|
||||
const [highlighted, setHighlighted] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// For now, use simple highlighting with proper escaping
|
||||
// In production, you'd want to use Shiki or Prism for better highlighting
|
||||
const escaped = code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
setHighlighted(escaped)
|
||||
}, [code])
|
||||
|
||||
if (!highlighted) {
|
||||
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">
|
||||
<code className="text-sm font-mono">{code}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<code
|
||||
className="text-sm font-mono"
|
||||
dangerouslySetInnerHTML={{ __html: highlighted }}
|
||||
/>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
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={[rehypeRaw, rehypeSanitize]}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { MarkdownContent } from './MarkdownContent'
|
||||
|
||||
interface ProjectDetailProps {
|
||||
project: {
|
||||
@@ -127,18 +128,14 @@ export function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
||||
</header>
|
||||
|
||||
{/* Article Content */}
|
||||
<article className="prose prose-lg dark:prose-invert max-w-none prose-headings:font-display prose-a:text-blue-600 dark:prose-a:text-blue-400 hover:prose-a:text-blue-800 dark:hover:prose-a:text-blue-300 prose-img:border-2 prose-img:border-black dark:prose-img:border-gray-600 prose-img:shadow-brutal dark:prose-img:shadow-none prose-ul:marker:text-black prose-ul:dark:marker:text-white">
|
||||
<article className="max-w-4xl mx-auto">
|
||||
{/* Lead paragraph */}
|
||||
<p className="lead font-display text-xl mb-8">{displayDescription}</p>
|
||||
<p className="lead font-display text-xl mb-8 text-gray-700 dark:text-gray-300">
|
||||
{displayDescription}
|
||||
</p>
|
||||
|
||||
{/* Full content */}
|
||||
{displayContent && (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: displayContent.replace(/\n/g, '<br />'),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Full content with Markdown rendering */}
|
||||
{displayContent && <MarkdownContent content={displayContent} />}
|
||||
|
||||
{/* Installation section if GitHub link exists */}
|
||||
{project.links.some((l) => l.type === 'GITHUB') && (
|
||||
|
||||
Reference in New Issue
Block a user