用 Astro Content Collections 管理 Markdown 文章

写静态博客最省事的做法是直接把 Markdown 丢进目录,但那样 frontmatter 写错了要到页面上才发现。Astro 的 Content Collections 可以给 frontmatter 定 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(),
    date: z.coerce.date(),
    category: z.enum(['daily', 'study', 'research']),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
});

export const collections = { posts };

几个实际有用的点:

  1. z.coerce.date() 会把 frontmatter 里的字符串日期直接转成 Date,排序时不用自己 new Date()。
  2. z.enum([...]) 把分类锁死,写错分类构建期就报错,不会出现一篇没归档的孤儿文章。
  3. z.array(z.string()).default([]) 让 tags 可以省略,不用每篇都写。

坑

坑一:集合 id 与文件路径。 用 glob loader 时,文章的 id 是相对 base 的路径去掉扩展名,所以 posts/foo/bar.md 的 id 是 posts/foo/bar,路由里要用 [...id].astro 才能兜住多级路径。

坑二:import.meta.env.DEV 不能用在 getCollection 的回调外。 想在开发时显示草稿、构建时隐藏草稿,得把判断写在过滤回调里:

const posts = await getCollection('posts', ({ data }) => import.meta.env.DEV || !data.draft);

坑三:中文标签不能直接做 URL。 encodeURIComponent 之后整段路径都会被转义,链接很难看。这里的做法是非 ASCII 标签用 ASCII slug 存,构建期生成映射表。

小结

schema 校验的价值不在”更规范”,在于错误提前。frontmatter 少写一个字段,本地 npm run build 就会停下并告诉你哪个文件哪一行,比部署完发现页面空白强。