静态站点最容易出问题的地方不是组件,而是内容。少写一个字段、日期格式写错、图片路径拼错,页面不会报错,只会安静地显示 undefined 或者空白。等到有人打开链接才发现,往往已经过了几天。
问题的本质
Markdown 的 frontmatter 是一段 YAML,而 YAML 没有类型。构建工具读进来就是一个普通对象,模板里写 post.data.pubdate 和 post.data.pubDate 都能通过,只是其中一个永远是 undefined。
---
title: 我的文章
pubdate: 2026-09-12 # 少了一个大写字母
---
这种错误肉眼极难发现,但类型系统一眼就能看出来——前提是给它一个类型。
内容集合提供的那一层
Astro 的内容集合把「目录里的 Markdown」抽象成带类型的集合,schema 用 Zod 描述:
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/[^_]*.{md,mdx}' }),
schema: z.object({
title: z.string().max(80),
description: z.string().max(200),
pubDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
三个细节值得注意:
- 配置文件是
src/content.config.ts,不是早期版本里的src/content/config.ts。 - 日期必须用
z.coerce.date()。YAML 里的2026-09-12可能被解析成字符串,严格的z.date()会直接校验失败。 pattern: '**/[^_]*.{md,mdx}'会跳过_开头的文件,模板文件因此不会被当成真实内容。
让错误在构建时炸出来
schema 只有在读取内容时才生效,所以要把它放进构建流程:
{
"scripts": {
"build": "astro check && astro build"
}
}
astro check 同时做两件事:检查 TypeScript 类型,以及校验所有内容的 frontmatter。字段写错、类型不符、必填项缺失,都会让构建失败。CI 里这一步失败,部署就不会发生,线上永远是能跑通的版本。
经验是:宁可构建失败,也不要静默降级。
别给必填字段加默认值
一个常见的偷懒写法:
title: z.string().default('未命名'),
看起来很稳,实际上把「写错了」变成了「看起来正常」。三个月后你会发现列表里躺着一篇叫「未命名」的文章,而你已经想不起来它本来该叫什么。
默认值只应该给语义上确实可选的字段,比如 draft(默认不是草稿)、tags(默认没有标签)。标题、摘要、日期这类决定内容身份的字段,缺了就该报错。
排序只写一次
另一个容易不一致的地方是顺序。首页取三篇最新文章,列表页取全部文章,如果两处各写一遍排序逻辑,很容易出现首页和列表页顺序不同。
把这类查询收进一个模块:
export async function getPosts() {
const all = await getCollection('blog', ({ data }) => !data.draft);
return all.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
}
页面只调用 getPosts(),顺序永远一致,将来要改排序规则也只改一处。
收益
改造之后,写内容的流程变成:复制模板 → 填字段 → 本地构建 → 通过就推送。错误被挡在提交之前,而不是等读者发现。对一个长期维护的站点来说,这比任何视觉优化都重要——内容会一直增加,而检查成本应该保持恒定。