内容管线复盘:用 frontmatter 把 MDX 博客变成结构化数据
从 HUAN Lab 的文章读取、frontmatter 校验、标题提取和列表生成出发,复盘一个轻量博客如何把本地 MDX 变成可查询的结构化内容。
一个个人博客最容易被低估的部分,不是页面,也不是样式,而是内容管线。只要文章数量开始增加,页面就不应该再手写列表;更稳的做法是让每篇 MDX 自己携带必要元数据,再由程序统一读取、校验和派生索引。HUAN Lab 当前采用的就是这个路线:文章放在 content/posts,文件名变成 slug,frontmatter 变成页面、分类、标签和搜索需要的数据。[source-posts]
内容管线的价值在于把“写文章”这件事约束成可重复流程:作者写 MDX,程序负责读取、校验、排序和索引。
从文件系统开始
HUAN Lab 的文章入口集中在 lib/posts.ts。代码把 process.cwd() 下的 content/posts 作为文章目录,并只读取 .mdx 文件。[source-posts] 这意味着内容管理没有引入数据库,也没有依赖外部 CMS;新增文章的最小动作就是新增一个文件。
这个约束带来三个直接好处:
- 文件名天然承担 URL slug 的职责
- Git diff 可以完整记录文章变化
- 构建阶段能一次性发现 frontmatter 或正文结构问题
const postsDirectory = path.join(process.cwd(), "content", "posts");
export function getAllPosts() {
if (!fs.existsSync(postsDirectory)) return [];
return fs
.readdirSync(postsDirectory)
.filter((filename) => filename.endsWith(".mdx"))
.map(readPostFile)
.sort((a, b) => b.date.localeCompare(a.date));
}这段设计的重点不是技巧,而是边界清晰:内容目录只管存文章,读取函数只管把文件变成 Post 对象,页面层只消费结构化结果。[source-posts]
frontmatter 是内容契约
博客里的每篇文章都需要 title、date、category、tags、description、featured 和 cover。这些字段不是装饰,它们分别参与标题展示、日期排序、分类过滤、标签索引、摘要展示、精选区和封面路径管理。[source-posts]
lib/posts.ts 使用 zod 定义 frontmatter schema,并在读取单篇文章时调用 safeParse。如果校验失败,构建会抛出包含文件名的错误。[source-posts] 这比在页面组件里兜底要更硬一点,但也更适合静态博客:内容错误应该在发布前暴露,而不是上线后才以空白字段的方式出现。
const frontmatterSchema = z.object({
title: z.string().min(1),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
category: z.string().refine(isCategory, {
message: `category must be one of: ${categories.join(", ")}`
}),
tags: z.array(z.string().min(1)).min(1),
description: z.string().min(1),
featured: z.boolean(),
cover: z.string().min(1)
});从工程角度看,这里有一个取舍:严格 schema 会让写作时多几个必填项,但换来的是列表页、搜索页、分类页和文章页都能相信数据结构稳定。综合当前代码可以推断,这个项目更偏向“构建时失败”而不是“运行时猜测默认值”。[source-posts][source-categories]
派生字段让页面更轻
读取文章时,HUAN Lab 不只是返回 frontmatter 和正文,还会派生 formattedDate、plainText、readingTime 和 headings。[source-posts] 这些字段有几个用途:
formattedDate让页面不用重复处理日期格式plainText给阅读时间和搜索索引使用readingTime给文章列表和详情页提供阅读预估headings给文章目录组件使用
标题提取也放在内容层完成。extractHeadings 只处理 ## 和 ###,并用 headingId 生成目录锚点。[source-posts] 这个规则很克制:文章内部只需要维护二级和三级标题,页面就能生成一个不会过深的目录。
分类和标签的边界
分类不是任意字符串,而是来自 lib/categories.ts 中的固定集合;标签则允许文章自由填写,并通过 getTagCounts 聚合成标签云。[source-categories][source-posts] 这是一种常见但有效的分层:
- 分类用于稳定导航,数量应该少
- 标签用于表达细分主题,数量可以随着内容增长
如果把分类也做成完全自由输入,后期很容易出现“技术教程”“技术笔记”“教程”这样的近义分裂。当前实现用 isCategory 在 frontmatter 校验阶段卡住这类问题。[source-categories][source-posts]
可以继续改进的地方
当前内容管线已经能支撑轻量个人博客,但仍有几个值得后续迭代的方向:
- 在构建时检查
cover指向的文件是否存在 - 给文章增加
updated字段,用于区分发布时间和修订时间 - 把来源引用、草稿状态等扩展字段纳入 schema
- 为
readingTime的中文显示修复编码问题
这些不是必须马上做的功能。更重要的是先保持主链路稳定:写 MDX、校验 frontmatter、生成页面、构建通过。只要这条链路可靠,博客就不会因为内容变多而变得难维护。
参考资料
- [source-posts]
lib/posts.ts,HUAN Lab 文章读取、frontmatter 校验、排序、标题提取与派生字段逻辑。 - [source-categories]
lib/categories.ts,HUAN Lab 固定分类集合与分类校验函数。