内容站的 bug 大多不是代码 bug,而是数据 bug:写错的日期、被改名的文章留下的死引用、缺 frontmatter 字段的半成品。这些问题构建不会报错,只会在读者点开的瞬间变成空白页或 404。解法是给内容层装一道“构建期校验”。
值得校验的六类问题
1. 必填与格式:字段缺失在 schema 层拦(zod/内容集合 schema),格式在脚本层兜:
// 阅读时长字段必须符合 "N 分钟",避免混入 "5 mins" / "约五分钟"
const readingTimePattern = /^[1-9]\d*\s*分钟$/;
2. 引用完整性:relatedPosts、项目关联文章、系列成员这些“指向别的文档”的字段,构建期逐个解析目标是否存在:
for (const ref of data.relatedPosts ?? []) {
if (!postIds.has(ref)) issues.push(`${file}: relatedPost "${ref}" 不存在`);
}
这是收益最高的一条——slug 改名后忘记同步引用,前端表现为“关联文章点开 404”,而只有这条断言能提前抓到。
3. 日期逻辑:updatedAt < publishedAt 必然是笔误(除非你允许未来预约发布,那也要单独校验);RSS 排序、图谱时间衰减都依赖这个不变量。
4. slug 唯一性:不同目录同名 slug、大小写碰撞,在静态路由生成阶段直接互相覆盖——提前在校验里报。
5. 公开产物的草稿泄漏:draft: true 的文章绝不能出现在 search-index.json、graph-data.json、RSS、sitemap、dist/ 里。这条要作为发布门禁(对每个公开产物断言不含任一草稿 ID):
const draftSlugs = collectDraftIds(); // 从内容集合读
for (const artifact of ['search-index', 'graph', 'rss', 'sitemap']) {
const leaked = findIdsInArtifact(artifact).filter((id) => draftSlugs.has(id));
if (leaked.length) fail(artifact, leaked);
}
6. 覆盖度报表:必填字段覆盖率、各集合数量(文章/踩坑/项目),输出成机器可读 JSON 供后台“内容健康”页消费——把“还差多少内容达标”从感觉变成数字。
分层:schema 管结构,脚本管语义
内容集合 schema(zod)适合声明式约束:类型、枚举、默认值、正则。它管不了跨文档引用和集合级统计。两级配合:
zod schema(结构合法性)→ check-content.mjs(语义/引用/统计)→ 生成器(索引/图谱/RSS)
顺序很关键:校验在生成之前,坏数据直接让构建失败,而不是生成出半错的索引再靠下游兜。本项目把它接进 prebuild,任何 npm run build 自动执行。
严重度与豁免
校验脚本要区分 error(挡构建)与 warning(记录不挡):缺标题是 error,缺 difficulty 可以只是 warning(有默认值)。全 error 策略的结局一定是有人绕过门禁,分层策略才能长期活着。临时豁免要有过期日:
// 允许豁免,但必须写理由和到期日,过期自动升级为 error
{ file: 'old-post.mdx', rule: 'missing-updatedAt', until: '2026-10-01', reason: '存量迁移中' }
反馈形态决定执行力
校验结果不要只丢在终端日志里。落一份 content-health.json,后台页渲染成清单——作者改内容时看到的应该是“3 篇文章缺 description”这样可执行的条目,而不是一屏 grep 输出。数据、门禁、可视化三件套齐了,内容卫生才是可持续机制而不是一次性大扫除。
小结
代码有类型系统兜底,内容凭什么裸奔?把 frontmatter 当 schema、把跨文档引用当外键、把公开产物当边界测试对象,用一条 prebuild 命令把六类数据 bug 挡在构建期——这是内容站在“写作自由”和“发布可靠”之间唯一划算的折中。