阅读时间 3 分钟最后更新: 2025-12-31 12:12

博客系统设计方案

本文档描述了为 Next.js 项目接入博客系统的详细设计方案,支持 MDX/MD 文档解析为静态页面,并支持多语言(en, zh-CN, ja)。

技术选型

推荐方案:react-markdown + next-mdx-remote

使用 react-markdown 配合 next-mdx-remote 实现 MDX 渲染,这是一个轻量级且灵活的方案。

核心依赖

包名用途
react-markdownMarkdown 渲染
next-mdx-remoteMDX 远程/动态渲染
gray-matter解析 frontmatter
rehype-highlight代码语法高亮
rehype-slug为标题添加 id
remark-gfm支持 GitHub Flavored Markdown

方案优势

  1. 轻量级 - 无需额外构建步骤
  2. 灵活性高 - 可以动态加载内容
  3. 成熟稳定 - 广泛使用的库
  4. 易于集成 - 与 Next.js App Router 完美配合
  5. 支持 MDX - 可在 Markdown 中使用 React 组件

目录结构设计

project-root/
├── content/
│   └── blog/
│       ├── getting-started/
│       │   ├── en.mdx              # 英文版本
│       │   ├── zh-CN.mdx           # 中文版本
│       │   ├── ja.mdx              # 日文版本
│       │   └── thumbnail.jpg       # 文章缩略图
│       ├── nextjs-best-practices/
│       │   ├── en.mdx
│       │   ├── zh-CN.mdx
│       │   ├── ja.mdx
│       │   └── thumbnail.png
│       └── [...更多文章]/
│
├── src/
│   ├── app/
│   │   └── [lang]/
│   │       └── (main)/
│   │           └── blog/
│   │               ├── page.tsx           # 博客列表页
│   │               └── [slug]/
│   │                   └── page.tsx       # 博客文章详情页
│   │
│   ├── components/
│   │   └── blog/
│   │       ├── BlogCard.tsx              # 文章卡片组件
│   │       ├── BlogList.tsx              # 文章列表组件
│   │       ├── BlogContent.tsx           # 文章内容渲染组件
│   │       ├── CodeBlock.tsx             # 代码高亮组件
│   │       ├── ReadingTime.tsx           # 阅读时间组件
│   │       └── MDXComponents.tsx         # MDX 自定义组件映射
│   │
│   └── lib/
│       └── blog/
│           ├── index.ts                  # 博客工具函数导出
│           ├── api.ts                    # 博客数据获取 API
│           └── types.ts                  # 类型定义

MDX 文章格式

Frontmatter 结构

---
title: "Getting Started with Next.js"
description: "A comprehensive guide to building modern web applications"
date: "2024-12-17"
author: "John Doe"
thumbnail: "/blog/getting-started/thumbnail.jpg"
published: true
---

# Getting Started with Next.js

Your article content here...

支持的 Frontmatter 字段

字段类型必填描述
titlestring文章标题
descriptionstring文章摘要/描述
datestring发布日期 (YYYY-MM-DD)
authorstring作者名称
thumbnailstring缩略图路径
publishedboolean是否发布(默认 true)

核心实现

博客数据获取 API

// src/lib/blog/api.ts
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { BlogPost, BlogPostMeta } from './types'

const BLOG_DIR = path.join(process.cwd(), 'content/blog')

// 获取所有博客文章的元数据
export async function getAllPosts(locale: string): Promise<BlogPostMeta[]> {
  const slugs = fs.readdirSync(BLOG_DIR).filter(name => {
    const stat = fs.statSync(path.join(BLOG_DIR, name))
    return stat.isDirectory()
  })

  const posts: BlogPostMeta[] = []

  for (const slug of slugs) {
    const filePath = path.join(BLOG_DIR, slug, `${locale}.mdx`)
    
    if (!fs.existsSync(filePath)) continue

    const fileContent = fs.readFileSync(filePath, 'utf-8')
    const { data } = matter(fileContent)

    if (data.published === false) continue

    posts.push({
      slug,
      title: data.title,
      description: data.description,
      date: data.date,
      author: data.author,
      thumbnail: data.thumbnail,
      readingTime: calculateReadingTime(fileContent, locale),
    })
  }

  return posts.sort((a, b) => 
    new Date(b.date).getTime() - new Date(a.date).getTime()
  )
}

// 获取单篇文章
export async function getPostBySlug(
  slug: string, 
  locale: string
): Promise<BlogPost | null> {
  const filePath = path.join(BLOG_DIR, slug, `${locale}.mdx`)
  
  if (!fs.existsSync(filePath)) return null

  const fileContent = fs.readFileSync(filePath, 'utf-8')
  const { data, content } = matter(fileContent)

  return {
    slug,
    title: data.title,
    description: data.description,
    date: data.date,
    author: data.author,
    thumbnail: data.thumbnail,
    content,
    readingTime: calculateReadingTime(content, locale),
  }
}

// 计算阅读时间
function calculateReadingTime(content: string, locale: string): number {
  const wordsPerMinute = locale === 'zh-CN' || locale === 'ja' ? 400 : 200
  const text = content.replace(/---[\s\S]*?---/, '') // 移除 frontmatter
  const wordCount = locale === 'zh-CN' || locale === 'ja' 
    ? text.length 
    : text.split(/\s+/).length
  return Math.max(1, Math.ceil(wordCount / wordsPerMinute))
}

类型定义

// src/lib/blog/types.ts
export interface BlogPostMeta {
  slug: string
  title: string
  description: string
  date: string
  author?: string
  thumbnail?: string
  readingTime: number
}

export interface BlogPost extends BlogPost