阅读时间 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-markdown | Markdown 渲染 |
next-mdx-remote | MDX 远程/动态渲染 |
gray-matter | 解析 frontmatter |
rehype-highlight | 代码语法高亮 |
rehype-slug | 为标题添加 id |
remark-gfm | 支持 GitHub Flavored Markdown |
方案优势
- 轻量级 - 无需额外构建步骤
- 灵活性高 - 可以动态加载内容
- 成熟稳定 - 广泛使用的库
- 易于集成 - 与 Next.js App Router 完美配合
- 支持 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 字段
| 字段 | 类型 | 必填 | 描述 |
|---|---|---|---|
| title | string | ✅ | 文章标题 |
| description | string | ✅ | 文章摘要/描述 |
| date | string | ✅ | 发布日期 (YYYY-MM-DD) |
| author | string | ❌ | 作者名称 |
| thumbnail | string | ❌ | 缩略图路径 |
| published | boolean | ❌ | 是否发布(默认 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