全部笔记

用内容 Schema 把编辑错误拦在构建之前

为 Astro 文章建立可执行的元数据契约,并区分格式正确与内容可信。

一篇文章把 draft: false 写成了 draft: "false",页面仍然可能顺利渲染。对 JavaScript 来说,非空字符串是真值;如果过滤条件写得不严谨,发布结果与编辑者理解就会相反。内容集合的 Schema 应该阻止这种状态进入页面代码。

这里讨论 Astro 内容加载器读取 Markdown 的情况。项目版本不同,导入路径和内容 API 可能变化,应以项目锁定版本的文档为准。目标不是追求最多字段,而是让发布条件与页面实际依赖保持一致。

校验那些真正参与决策的字段

import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';

const articles = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/articles' }),
  schema: z.object({
    title: z.string().trim().min(1),
    description: z.string().trim().min(20),
    draft: z.boolean().default(true),
    tags: z.array(z.string().trim().min(1)).max(5),
    publishedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  }),
});

export const collections = { articles };

默认 draft: true 是一个偏保守的选择:漏写字段不会自动公开。描述的最小长度能阻止空摘要,却不能证明摘要有用。日期正则只验证外形,2026-02-31 仍然可能匹配,因此还需要日历层面的有效性校验,或采用有明确时区约定的日期解析策略。

不要为了自动通过而把所有字段都变成可选。页面需要标题和发布日期,就应要求它们存在。真正可选的字段,例如封面图,组件应有明确的缺省布局,而不是把 undefined 输出到 URL 中。

前面的简化示例要求所有记录都有 publishedAt,适合加载器只读取准备公开的文章,却不适合完整编辑系统。草稿尚未决定时间时,强制填日期会诱导编辑者填写虚假值。更合理的内部契约区分草稿与待发布记录:草稿有标题、描述和标签;准备发布的记录还需要有效发布时间、审核标识及稳定 slug。不要把“暂未填写”与“格式错误”混成同一种失败。

可以通过判别联合表达这些状态,也可以在发布转换中独立校验。前者让类型系统直接识别字段是否存在,代价是编辑工具需要理解多种记录形态;后者让私人草稿保持简单,但发布程序承担补齐与验证责任。对于个人博客,发布时生成公开元数据往往更直观;多人内容平台则可能需要显式工作流状态,避免编辑、审批、排期在同一个布尔值里竞争。

日期的约束也需要拆成三个层次。格式合法是第一层,日历日期真实存在是第二层,业务时间合理是第三层。例如发布时间晚于当前时间并不一定非法,可能只是排期;更新时间早于发布时间却通常值得拒绝。校验不能把所有未来日期都删除,也不能为了容纳排期而允许任意字符串。测试时固定当前时刻,避免一个样本今天通过、明天失败。

在边界处完成转换,页面只处理标准形态

假设历史文章的标签同时存在数组和逗号分隔字符串。直接让 Schema 接受两种类型,短期能构建,却会把每个页面都变成兼容层。更稳妥的迁移是读取原始数据时识别旧格式,转换成统一数组,再执行正式约束;完成迁移后移除旧输入支持。这样页面获得的类型只有一种,迁移行为也有明确退出时间。

转换必须可见。空字符串分割后得到空数组,应报告原文件,而不是静默塞入一个“其他”标签。大小写归一化也可能合并原本不同的词,应提供变更清单让编辑者确认。默认值适合表达有意设计的缺省状态,不适合掩盖缺失证据。把错误发布日期默认成构建时间尤其危险,重新构建会让同一篇文章看起来刚刚发布。

图片字段可以进一步表达资产契约:本地封面要求存在、替代文本不能为空,远程地址要求允许的协议。不能只验证地址语法就认为图片能够下载,也不要在构建时无限等待远程服务器。资产存在性、文件大小和尺寸属于独立检查,可以与元数据检查一起给出报告。对没有封面的文章保留合法状态,比强迫作者使用无意义装饰图更好。

本地字段正确,不等于集合正确

Schema 很适合校验单篇文章,但无法自然回答“两个文件是否使用同一个 slug”。这类跨记录约束需要额外检查。构建脚本可以维护一个 Map,遇到重复 slug 时输出两个文件名并失败。标签也可以使用规范词表,防止 Cloudflare、cloudflare 和 CF 在筛选器中变成三个主题。

另一类约束是路径安全。slug 应限制为小写 ASCII 字母、数字和连字符,禁止路径分隔符、前后空白以及保留路由名。即使静态站点不涉及数据库注入,不可信路径也会制造链接冲突与难以预测的构建输出。

一次只报第一个错误在小项目中够用,但批量导入几十篇文章时会拖慢修复。可以先对每个文件做字段校验,收集错误;仅对字段合法的记录继续做 slug、路径和引用一致性检查;最后一次性输出按文件分组的诊断并让构建失败。这样不会因为一个非法 slug 而误报几十个路径冲突,也不会把失败记录混入后续渲染。

错误信息要区分“输入值”“期待规则”和“修复建议”。例如日期错误可以指出必须包含时区,标签错误可以列出已支持词表;但不要把全文或私密摘要输出到公开 CI 日志。日志是否公开与草稿是否进入网站是两个不同边界,私人写作目录不应由公共流水线扫描并打印。

还需要明确未知字段策略。拒绝未知字段能发现 publisedAt 之类拼写错误,允许未知字段方便独立工具保存扩展信息。两者没有通用答案。若存在多人编辑与多个工具,采用专门的扩展对象,比让任何拼错字段都悄悄通过更容易维护。Schema 的严格程度应根据作者能否理解错误、升级是否可控来决定。

草稿隔离不能只靠页面过滤

getCollection('articles', ({ data }) => !data.draft) 可以隐藏列表中的草稿,但如果详情页的 getStaticPaths() 使用未过滤集合,草稿仍然会拥有可直接访问的地址。RSS、站点地图、标签页面和站内搜索同样要使用相同的公开集合。

更强的隔离是让私人草稿根本不进入公开内容加载器:把草稿放在构建输入之外,发布时才复制审核通过的文件。Schema 是发布输入的契约,不是权限系统。私有内容出现在仓库或部署资产中,即使导航没有链接,也不能称为私有。

文章昨天通过审核,今天正文又改了,元数据仍然合法,但审核结论不再对应当前内容。发布系统可以保存稿件的内容指纹,发布前重新计算并比较;有变化就要求重新审核。指纹说明“内容是否相同”,不说明“内容是否正确”,也不能作为作者身份认证。它只是防止工作流无意间复用过期批准。

计算指纹时要明确范围。如果摘要、标题和引用都影响公开内容,就不能只对正文求摘要;如果排期日期由发布程序生成,可以把内部审批对象与最终公开记录分别记录。不要为了减少重审而忽略代码块,技术文章中一个字符的变化就可能改变命令的破坏性。

发布转换完成后再次运行公开 Schema,确保转换器没有遗漏字段,再以临时目录构建。只有全部检查通过才替换正式输出。这样即使源稿合法、转换器存在缺陷,也不会留下半篇文章或不完整索引。将输入校验、集合校验和产物检查分开,错误才有清晰归属,而不是所有问题都被称为“构建失败”。

数据契约还需要与模板依赖一致

某个布局开始显示阅读时间,却没有要求内容提供阅读时间或可靠计算方式,Schema 再严格也无法阻止页面出现空值。每次新增模板依赖,都要决定数据来自编辑者、构建计算还是运行时服务。可计算字段应由同一个构建步骤生成,避免作者手填字数与正文变化脱节;不可推导的字段则需要明确输入和校验。

以阅读时间为例,算法需要说明中文字符与英文词如何估算、是否计入代码。它给的是阅读参考,不是承诺每位读者都需要同样分钟数。计算结果不应决定文章事实是否成立,但可以帮助发现异常短稿。测试混合中英文、代码块与空正文,确认结果不会因为计数器漏掉中文而显示一分钟。

摘要也是模板契约的一部分。自动截取正文可能包含 Markdown 标记、半个链接或未解释的代码,适合兜底,不一定适合搜索结果。人工摘要应描述文章解决什么问题与适用边界,校验只能确认它存在、长度合理、没有明显格式残留。不要把固定最小字数等同于信息密度,也不要因为摘要足够长就跳过编辑审查。

维护一份页面所需字段清单,比增加几十个从不使用的元数据更有效。删除字段时检查 RSS、搜索索引和结构化数据是否仍依赖它;新增字段时检查旧文是否需要迁移。只有输入约束与输出需求同步变化,类型系统才能减少真实错误,而不是提供一种“所有对象都通过了验证”的错觉。

用一个导入任务划清原始资料与发布记录

假设要导入二十篇旧站文章,其中日期有纯日期、带时区时间和空值,标签有数组也有自由文本。先保存原始资料及来源标识,不要一边解析一边覆盖。随后为每条记录输出转换结果与诊断:哪些字段原样保留,哪些按确定规则转换,哪些必须人工决定。原始资料让失败可以重现,转换记录让作者知道系统究竟改了什么。

纯日期不应该无声转换成某个时区的午夜再参与排期,因为作者可能只想表达文章所属的日历日。若发布系统要求时间点,需要明确站点时区及缺省时间,并让导入记录注明该值来自规则而非原稿。已经带时区的时间也不要通过本地机器的时区重解释。输入、标准内部值、显示值分别承担不同职责,混在一个字符串里会让迁移结果随运行机器变化。

slug 的迁移更需要外部映射。旧链接可能已经被引用,不能因为新规则要求小写连字符就直接重写而不保留重定向计划。每条记录应有旧地址、新地址与冲突处理结果。发现两个旧地址归一化到同一个新地址时,停下来选择稳定标识,而不是按导入顺序加数字。后者看似构建通过,下一次改变文件排序就可能产生另一套地址。

最后把已确认记录送入正式 Schema,未确认记录留在隔离区。导入工具不应为了达到“二十篇全部完成”而把错误对象强制标记为草稿并送入公开加载器。草稿状态表示编辑意图,隔离状态表示转换失败;这两种状态若使用同一个字段,后续发布程序可能误把失败输入当作只差日期的正常文章。

契约变更需要可回放的诊断集

Schema 的测试不只保存合法样本,也保存过去真实出现过的错误形态。可以给诊断定义稳定代码,例如日期不存在、路径冲突和缺少发布证据。用户看到的说明可以改进,测试主要断言代码、文件和字段路径。这样第三方校验库更新措辞时,不会产生大量没有行为变化的测试失败。

诊断还要有严重程度。缺少必需标题应阻断发布;摘要偏长可以是编辑警告;引用是否支持结论仍需人工判断。不要用一个“全部通过”的绿灯混合这些结论。生成报告时分别展示阻断项、建议项与需要人工确认的事项。没有格式错误不等于文章已经可以公开,而人工批准也不应该绕过构建输入的硬约束。

针对日历校验,使用闰年、月份边界、非法天数和明确时区样本。针对路径,使用大小写差异、前后空白、保留名称与归一化冲突。针对发布状态,测试缺字段、字符串布尔值、未审核记录和指纹变化。负向测试的意义在于证明边界会拒绝,而不是证明代码能够为任意输入找到一个兜底。

可回放诊断集还帮助升级依赖。先在同一批输入上运行旧版与新版,比较接受集合和标准化结果,而非只比较错误数。新版少报三个错误,也许是规则改善,也许是静默接受了不安全状态。对每项差异做明确分类,再决定是否迁移。真实私人文章不应复制进公开测试仓库,可以用最小化且去除个人内容的样本重现结构问题。

生成产物之后再核对内容投影

一条通过校验的公开记录会投影成多个产物:正文页面、列表摘要、RSS 条目、站点地图地址和搜索文本。每个投影都可能引入新的错误。例如正文已经更新,搜索索引仍读取旧缓存;列表显示人工摘要,RSS 则错误地截取带代码围栏的原始正文。输入契约不能独自证明这些输出一致。

可以构造一篇测试文章,让标题、摘要、正文和 slug 都有独立且无敏感性的标记。构建完成后解析对应 HTML、RSS 与站点地图,断言标记出现在正确位置,并确认私人草稿的标记不存在。这里应使用格式解析器,而不是仅搜索文件中是否含有字符串:HTML 标题转义和 XML 编码会改变表面文本,但仍可能具有相同语义。

产物核对也要检查删除。如果公开文章改为私有,旧目录中的页面是否仍然保留?增量构建若只覆盖新文件而不清理旧产物,就可能让已经退出集合的地址继续访问。测试应该先生成公开页面,再改变输入并重新构建,验证旧地址在最终部署目录不存在。新建一个干净目录构建,再原子替换正式目录,是更容易理解的隔离方式,但仍需检查部署平台实际上传哪个目录。

公开集合与审核记录之间可以输出一个清单,列出每条公开 slug 的输入指纹及产物路径。它帮助复核某篇文章是否来自预期版本,不应包含私人正文或完整私有清单。站点地图中的地址数量与公开清单数量不一定相同,因为还有首页、标签和其他页面;核对时比较文章子集,避免把数量凑齐当作内容正确。

别让编辑便利绕过可信边界

预览工具为了方便作者,往往接受更宽松的输入。若宽松预览与严格发布复用同一个函数,应显式区分运行模式,并测试正式发布不能调用跳过校验的分支。环境变量缺失时应选择严格模式,而不是默认放行;配置拼错也应报错,不能变成另外一个可以公开草稿的模式。

同时保留可解释的失败路径。预览可以显示“发布日期未填写”,但不能生成一个看起来真实的发布日期让作者误认为已经排期。批量修复工具可以建议标签归一化,却不应自动把有歧义的专有名称合并。编辑工具降低输入成本,Schema 约束确定的结构,审稿判断内容可信度,三者协同才能避免“方便”最终成为绕过发布边界的理由。

验证升级,而不只是验证当前数据

修改字段规则前,把现有内容跑一次新检查,保存不兼容清单。先修复或迁移内容,再启用强制失败;否则一次依赖升级可能让所有旧文无法构建。对从默认草稿变为显式发布的转换保留专门测试,确认输入缺字段时仍然失败关闭,而不是因默认值变化突然公开。

最小测试集应同时包含合法草稿、合法发布记录、缺失发布时间、非法日历日期、同路径冲突和旧格式迁移。断言错误的类别与来源,不必锁死整段第三方错误文本,以免工具升级造成无意义测试波动。最终的收益不是“Schema 很复杂”,而是页面代码不再重复猜测内容状态,编辑者能在发布前知道哪项契约没有满足。

假设准备删除旧字段 summary,改用 description。详情模板已经迁移,RSS 转换器却仍读取旧值。若第一步就从所有文件删掉 summary,Schema 可能通过,订阅摘要却变空。先列出字段消费者:模板、订阅、搜索、导入和预览各自记录读取位置;为同一条合成记录设置两个不同标记,确定最终输出究竟消费哪个值。

过渡期间不让两个字段各自编辑。指定 description 为权威输入,旧工具需要的 summary 由兼容转换生成;若输入同时提供且冲突,报告作者确认,而不是随读取顺序任选。等所有消费者切换后,正式输入拒绝旧字段,旧资料只允许经过显式导入模式转换。这样“旧字段仍能读”不会无限延长为“新稿也可以继续写旧字段”。

退出测试使用三份记录:只有新字段应通过,只有旧字段在正式发布中应失败,两个字段冲突应给出明确诊断。再构建一次 RSS 和搜索样本,验证新摘要实际出现。这个过程检验的是字段生命周期,不是增加更多必填项;每次收缩契约都能说明谁已不再读取旧值,才不会把 Schema 清理变成静默内容删除。

参考:Astro 内容集合、Zod。