静态站点有个隐藏的成本:内容错误往往不会报错,而是安静地消失。日期写错了、分类拼错了、字段名少了个字母,页面照样构建成功,只是那篇文章不再出现在任何列表里。等人发现时,通常已经过去很久。
问题的根源
早期的做法是“约定俗成”:所有文章都有 title、date、category,靠记忆保证格式统一。问题在于记忆不可靠,尤其在几个月后回来补一篇文章的时候。
解决思路只有一条:把约定变成可执行的校验。
用 Zod 定义内容结构
Astro 的内容集合允许为每类内容声明 schema,字段类型不对、必填项缺失都会在构建时报错:
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const posts = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
schema: z.object({
title: z.string().min(1),
date: z.coerce.date(),
category: z.string().refine(isRegisteredCategory, {
message: 'category 必须是 site.config.ts 中已注册的分类 key',
}),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
这段代码带来的实际收益是:写错分类时构建直接失败,并且错误信息会明确告诉你是哪一篇、哪个字段、可选值有哪些。
关于 z.coerce.date()
YAML 里的日期写法很容易不统一:
date: 2026-09-18 # 正常
date: "2026-09-18" # 也能用
date: 2026/09/18 # 会解析失败
用 z.coerce.date() 把字符串或日期对象统一转成 Date,后续排序、格式化就都不需要再做兼容处理。
校验之外:还要有一层“业务校验”
类型对了不代表逻辑对了。以下几类问题 Zod 管不了,需要在数据层补一个显式检查:
| 问题 | 表现 | 处理方式 |
|---|---|---|
| 分类未注册 | 文章出现在列表但无对应栏目页 | 在 schema 中用 refine 拦截 |
| 标签拼写不一致 | 同一主题分裂成多个标签 | 定期检查标签列表,合并同义标签 |
| 日期排序异常 | 置顶失效、顺序奇怪 | 排序函数里显式处理 pinned 与日期 |
| 摘要缺失 | 列表里摘要为空 | 从正文自动截取,保证有兜底 |
排序规则也要写死在一处
列表、归档、上下篇导航都依赖顺序。如果每处各写一套 sort,早晚会出现“三处顺序不一致”的诡异现象。正确做法是把规则集中到一个函数:
export function comparePosts(a: Post, b: Post): number {
if (a.data.pinned !== b.data.pinned) return a.data.pinned ? -1 : 1;
return b.data.date.valueOf() - a.data.date.valueOf();
}
所有页面都调用同一个函数,规则改变时只需要改一处。这是“可维护”最朴素也最有效的做法。
小结
内容校验的价值不在于“更严谨”,而在于降低心理负担:你可以放心地隔几个月再写一篇,不必回忆当初定的规矩,因为工具会替你记住。