Astro 5 把内容集合的配置从 src/content/config.ts 挪到了 src/content.config.ts,同时引入 loader 概念——内容可以来自本地 Markdown,也可以来自 API 或数据库。对博客来说,变化最大的是 id 现在是 slug,不再是 slug 字段。
配置一个集合
先建内容目录 src/content/blog/,然后在 src/content.config.ts 里声明:
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
z.coerce.date() 这个细节值得强调:YAML 里的 2026-10-19 会被解析成字符串,直接 z.date() 会在构建时报错。
id 与 slug
在 Astro 4 里要自己维护一个 slug 字段。Astro 5 之后,文件名就是 id:
src/content/blog/why-own-website.md → id: "why-own-website"
路由里直接拿它拼地址,不用再担心两者不一致:
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({ params: { slug: post.id } }));
}
渲染
entry.render() 被移到了 astro:content 的 render 函数上,返回值多了一个 headings,正好用来生成目录:
import { render } from 'astro:content';
const { Content, headings } = await render(post);
headings 里每项带 depth、slug 和 text,过滤 depth <= 2 就是一个像样的 TOC,不需要再引额外的插件。
草稿的处理
我不喜欢「本地看到草稿、线上看不到」这种差异靠人记。让构建自己判断更稳:
const includeDrafts = import.meta.env.DEV;
const posts = all.filter((post) => includeDrafts || !post.data.draft);
这样 npm run dev 时草稿带一个角标出现在列表里,npm run build 时直接从产物里消失。
一句提醒
内容集合的 schema 是构建期校验,不是运行时。写错 frontmatter 时构建会失败并指出是哪个文件哪一行——这比页面白屏后去翻控制台舒服得多,也是我坚持用它的主要原因。