Astro 内容层:把博客当作一个工程来维护
Content Collections 用 zod 校验 frontmatter,让文章字段的错误在构建期就暴露,而不是等你发现某篇文章从列表里消失了。
我搭过三套博客:Hexo、Hugo、现在是 Astro。前两套的痛点不在渲染,而在内容本身没有类型。
没有类型的内容层是什么样
Hexo 时代我犯过一个低级错误:把 date 写成了 data。结果那篇文章没报错,只是安静地从时间线里消失了。我是三个月后翻旧文才发现的。
这类问题的本质是:Markdown 的 frontmatter 是一段 YAML,而 YAML 对你的字段名没有任何约束。渲染器拿不到 data 就当你没写,继续用默认值往下跑。
Content Collections 的做法
Astro 的思路是把内容声明成一个带 schema 的集合:
// src/content.config.ts
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().max(120),
description: z.string().max(300),
publishedAt: z.coerce.date(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { posts };
现在 data 会直接让构建失败:
✗ src/content/posts/2026/foo.md
publishedAt: Required
失败在构建期,而不是三个月后的某个下午。这一条就够了。
附带的好处
查询变成类型安全的。 getCollection('posts') 返回的每一项,
data.publishedAt 都是 Date 而不是字符串,data.tags 一定是数组。你不需要在模板里写 post.tags || [] 这种防御性代码。
排序逻辑可以集中。 z.coerce.date() 保证所有日期都是 Date 对象,于是按时间排序只是一行比较函数,不会出现「有的文章是字符串,比较时错位」的经典 bug。
draft 是一等公民。 我在 schema 里放 draft: boolean,所有列表查询统一过滤:
const posts = await getCollection('posts', ({ data }) => !data.draft);
未完成的文章可以长期躺在仓库里跟着代码一起提交,永远不会被误发布。
一点代价
内容层的抽象意味着你得接受 Astro 的目录约定。如果你有一批历史文章散落在奇怪的路径下,迁移时得先整理一遍。
我的做法是迁移时顺手把 URL 也规范了:/posts/年份/slug/。旧链接用一份 redirects 配置兜住 —— 这是个一次性成本,但换来的是往后几年都不用再想这件事。
工程化不是为了让你写得更快,而是为了让错误更早暴露。