本篇是《手写一个内容型技术博客站》系列第 2 篇。上一篇聊了技术选型,这一篇讲内容层的架构核心:配置与内容分离。
很多内容站做大了以后会遇到两个问题:一是编辑改一篇文章要小心翼翼,生怕碰到配置;二是分类的排序、别名、SEO 关键字散落在代码里,改一处要 grep 全站。jiusi-notes 在 v3.25.0 做了一次重构,把这两类问题一起解决:所有元数据配置集中到 src/config/*.yaml,内容纯 Markdown 放在 src/content/,两者通过一个通用加载器对接。
这篇讲清楚三件事:分层的动机、加载器的实现、重构踩过的坑。
一、为什么要把配置和内容分开
早期版本沿用很多静态站的默认做法:配置文件和内容放在一起。比如这样:
src/content/categories/区块链/config.yaml # 分类配置
src/content/categories/区块链/erc20.md # 文章正文问题在"配置"和"内容"是两个生命周期完全不同的东西:
| 维度 | 配置(config) | 内容(content) |
|---|---|---|
| 变更频率 | 低,偶尔调整排序/关键词 | 高,日常写作 |
| 变更影响面 | 全局(导航/SEO/路由) | 单篇 |
| 出错后果 | 可能整站崩 | 只影响一篇文章 |
当编辑(或用户自己)往 categories 里新增一篇文章时,他大概率只关心"文章能不能发出去",不应该被旁边的 config.yaml 牵制。而配置一旦被内容操作误伤——比如一次批量脚本把 categories/config.yaml 动了——可能就是全站导航错乱。
更实际的一个痛点:排序、别名、SEO 关键字这些"元数据",天然应该是结构化的、可被程序校验的,而不是散落在内容文件的 frontmatter 里、或者硬编码在代码里。
v3.25.0 之后的边界非常清晰:
src/config/ ← 7 个 YAML:site/categories/pages/tutorials/hero/comments/albums
src/content/ ← 纯 Markdown 仓库:categories/pages/tutorials 三个目录配置怎么改都不碰内容,内容怎么加都不动配置。
二、配置驱动元数据:categories.yaml 设计
以分类为例。分类是内容站的骨架:导航、文章列表、SEO 都依赖它。这份配置要承载的可不只是"名字"。
# src/config/categories.yaml
categories:
- name: 基础理论
order: 10
slug: theory
description: 计算机基础、算法、数据结构、网络原理
keywords: [算法, 数据结构]
subcategories: []
- name: 计算机技术
order: 20
slug: computer-technology
subcategories:
- name: 操作系统
slug: os
order: 10
- name: 软件工程
slug: software-engineering
order: 40几个字段的设计意图:
- order:展示排序。没配的自动排最后(用
?? 9999兜底,见下)。 - slug:路由标识。覆盖默认的 slugify 结果——比如"基础理论"默认拼音是
jichulilun,但我想让它显示成theory,就在这里配。这是"配置覆盖约定"(convention-over-configuration)的关键:有配置用配置,没配置用约定。 - description / keywords:喂给 SEO。
- subcategories:二级分类内嵌。v3.13 起的推荐写法,直接缩进关联到所属一级分类,比旧的顶层
Record<"L1/L2", ...>清晰得多。
三、通用加载器:src/lib/content-config.ts
配置是外部的 YAML,代码怎么读它?核心是一个通用函数:
// src/lib/content-config.ts
import fs from "fs";
import path from "path";
import { load } from "js-yaml";
const CONFIG_ROOT = () => path.resolve(process.cwd(), "src/config");
/** 通用 YAML 读取:config 目录下的单文件,如 categories.yaml */
function loadYaml(configFile: string): Record<string, unknown> | null {
const configPath = path.resolve(CONFIG_ROOT(), configFile);
if (!fs.existsSync(configPath)) return null;
try {
const content = fs.readFileSync(configPath, "utf-8");
return load(content) as Record<string, unknown>;
} catch {
return null;
}
}然后针对每种配置导出强类型的加载函数:
export function loadCategoriesConfig(): CategoriesConfig {
const data = loadYaml("categories.yaml");
return {
categories: (data?.categories as CategoryConfig[]) || [],
subcategories: (data?.subcategories as Record<string, SubcategoryConfig>) || undefined,
};
}几个要点:
- 统一入口。调用方只 import
loadSiteConfig()/loadCategoriesConfig(),绝不直接碰src/config/xxx.yaml路径。这样以后目录再变,只改加载器一处。 - 容错。
loadYaml文件不存在返回null,解析失败返回null,调用方用|| []/|| {}兜底。配置坏了不应该让整站 500,降级到空值是内容站的底线。 - 类型化。每个配置都有对应的 TS 接口(
SiteConfig/CategoriesConfig/TutorialsConfig…),YAML 的弱类型在边界上被收口成强类型。
加载器最终把"磁盘上的 YAML"和"代码里的强类型"对接起来,页面层拿到的都是干净的配置对象。
四、order 排序的实现:?? 9999 兜底
排序是整个配置体系里最容易出 bug 的地方。一级分类和二级分类都要排:
// categories.ts 内部
const withOrder = (children, lookup) =>
children.map((node) => {
const cfg = lookup.get(`${node.name}/${node.name}`) /* ... */;
return node;
});
// 关键:未配置 order 的一律排最后
children.sort((a, b) =>
(subcatByKey.get(`${node.name}/${a.name}`)?.order ?? 9999) -
(subcatByKey.get(`${node.name}/${b.name}`)?.order ?? 9999)
);这个 ?? 9999 很关键:新分类忘了配 order,不会挤到最前面,而是安静地排到最后。这比"没配就报错"对编辑友好得多——配置是渐进完善的,不是一次全配齐。
踩坑记录:v3.24 之前,二级分类从不按配置的 order 排序。
applyCategoryConfig只对一级分类做了 sort,二级分类的children原样保留了readdirSync的文件系统顺序(安全→软件工程→python→web 开发这种乱序)。因为"文件系统顺序"在开发机上是稳定的,问题被掩盖了很久。后来新增分类才发现排序是错的。修法:children.map 之后补一个按子分类 order 的 sort。这类 bug 的教训是:配置里写了 order,代码就一定要真的消费它,别假设文件系统顺序等于配置顺序。
五、重构踩过的坑(v3.25.0 实修)
这次重构看起来是"搬文件 + 加加载器",但牵扯到几个容易漏的连带点:
坑1:签名缓存必须纳入配置文件
内容树有 computeContentSignature 做签名缓存——通过比对文件 mtime/size 判断内容有没有变,没变就用缓存,避免每次请求重建整棵内容树(文章多的时候有性能意义)。
迁移后配置文件路径变了,但签名函数只监听了 src/content/ 的 md/mdx。结果:改了 categories.yaml 的 order,签名没变,缓存不重建,排序不生效。**
修法:签名额外纳入 src/config/categories.yaml 的 stat。
const sigCat = statSync(pathToConfigCategories); // 加这一行
// computeContentSignature: [ ...contentStats, sigCat ]这类 bug 极其隐蔽——配置改了、代码没崩、页面用旧数据。验证方法:改完 order 起服务看导航顺序变没变。
坑2:磁盘目录和工作区的孤儿检测
配置和内容分开后,出现了一个新的不一致形态:磁盘有目录、config 没注册,或 config 注册了、磁盘目录名对不上。后果是文章被静默忽略(列表页不显示、SEO 失效),但 build 不报错。
这个靠 preflight 校验脚本兜底(本系列后面有专门一篇讲),核心逻辑:
// preflight-check.mjs 第 4 步:目录与 config 孤儿检查
// 遍历磁盘目录 vs config 注册的分类名,两边对不上就 WARN模块化是一个不断"把隐式约定变成显式校验"的过程。目录和配置的一致性,从"靠人记住"变成了"脚本检查"。
坑3:旧 config 路径的残留引用
迁移后 src/content/xxx/config.yaml 的老路径还散落在注释、README、甚至个别代码里。grep 全站 src/content/.*config\.yaml 逐处清掉。这个比不上前两个"功能性"坑,但会误导后来维护的人——注释里写错的路径比没有注释更危险。
六、这套架构的收益(以真实数据结算)
写这篇文章时站点实际状态:
- 7 个配置文件(site/categories/pages/tutorials/hero/comments/albums),合计约 500 行 YAML
- 200+ 篇 Markdown 内容(文章 + 教程 + 静态页)
- 编辑日常操作:往对应分类目录丢一个
.md,preflight-check.mjs校验通过即可;不需要碰任何配置 - 调整站点名称 / 排序 / SEO 关键字:改对应 YAML,
auto_rebuild.py检测到变化自动触发重建
内容与配置分离带来的最大改变是操作边界清晰:写内容的人不需要理解配置,调配置的人不需要关心内容。加上 preflight 校验把"静默错误"提前暴露,站点从"能跑"进化到"可维护"。
给普通人的一句话:把"经常变的"和"偶尔变的"分开管理,是让一个项目长期不乱的基础——写代码是这样,做内容站也是。
参考
- Next.js App Router 文档:https://nextjs.org/docs/app
- js-yaml:https://github.com/nodeca/js-yaml
评论
0评论加载中…