纸上山河 记录技术、思考与生活

用 Astro Content Collections 管好文章元数据

让构建期成为你的第一道防线

约 1 分钟 making

把文章放成 Markdown 文件后,frontmatter 就是唯一的数据入口。它和文章内容不同步、格式容易手误、也没有数据库的外键约束——所有问题都得靠校验拦下来。

声明 schema

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

const blog = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
  schema: z.object({
    title: z.string(),
    subtitle: z.string().optional(),
    publishDate: z.coerce.date(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
    pinned: z.boolean().default(false),
  }),
});

coerce.date() 是关键

这一行代码的选择,影响了日常写作的体验:

publishDate: z.coerce.date()   // ✅ 接受 '2026-10-11',内部是 Date
publishDate: z.string()        // ❌ 接受一切字符串,包括 '去年'

用 z.coerce.date() 时,你可以写 2026-10-11、2026/10/11,甚至某些宽松写法都会被转成真正的 Date 对象。而用 z.string() 的话,类型是字符串,每次格式化都要自己处理,而且拼错格式要到运行时才暴露。

构建期直接报错:

✘ [ERROR] Expected date, received string

这比线上发现「某篇文章的日期显示成 undefined」好得多。

draft 字段配合查询过滤

const posts = await getCollection('blog', ({ data }) => !data.draft);

草稿在本地 npm run dev 可见,构建时自动排除。忘记改 draft 就不会误发布——这是 schema 最实用的一个默认值。

类型自动推导

const posts = await getCollection('blog');
posts[0].data.title   // string,完全类型安全

schema 写错一个字,TS 立刻报错。这是 Content Collections 相比”随便读个 YAML 文件”最大的价值。

一点实践建议

  • summary 留作可选字段,不填时从正文自动截断
  • 标签用 z.array(z.string()).default([]),避免到处写 post.data.tags ?? []
  • 需要图片时用 image() helper,Astro 会自动做优化和尺寸推导

一个判断标准:如果某个字段你会手滑写错,它就应该被 schema 管起来。手滑是会发生的,工程应该假设它会发生。