AstroBlog 的文章不是页面目录里的散落 Markdown,而是由 Content Collections 管理的一组有 Schema 的内容数据。文章文件负责正文和 Frontmatter,src/content.config.ts 负责定义字段和 loader,src/utils/content-utils.ts 负责公开性、排序和派生关系,页面只消费已经校验过的集合条目。
posts 集合的 loader
文章集合使用 glob() 读取 src/content/posts/ 下的 Markdown 和 MDX 文件,并排除 _fixtures:
const postsCollection = defineCollection({ // 回归测试样本不能进入公开文章集合。 loader: glob({ pattern: ["**/*.{md,mdx}", "!_fixtures/**/*.{md,mdx}"], base: "./src/content/posts", }), schema: z.object({ // 标题和发布日期是生成文章路由所需的最小字段。 title: z.string(), published: z.date(), draft: z.boolean().optional().default(false), tags: z.array(z.string()).optional().default([]), category: z.string().optional().default(""), }),});实际 Schema 还包含 updated、description、image、lang、layout、toc、coverAlt、pinned、author、许可证字段、comment、password 和 order,以及由内容查询函数写入的上一篇/下一篇字段。必填字段缺失或类型不匹配时,构建阶段会直接报错,而不是生成字段不完整的页面。
content.config.ts 同时定义动态、番组、生活记录、相册、导航、友链、更新日志等集合。可选集合使用空 loader 处理目录为空的情况,因此某个功能没有内容时,页面可以显示空状态而不让整个站点构建失败。
Frontmatter 是数据契约
文章文件的 Frontmatter 会被解析成类型化对象。日期必须能够被 Astro 转换为 Date,标签必须是字符串数组,layout 只能是 standard 或 wide,pinned 和 draft 必须是布尔值。后台编辑器如果写出字符串形式的 "false",就可能与 Schema 预期的布尔值不同。
文章内容可以使用 Markdown,也可以使用 MDX。两者都进入同一个 unified 管线,区别在于 MDX 允许更复杂的组件表达。正文中的图片、代码块、公式、Mermaid、提示框和 GitHub 指令由 remark/rehype 插件统一处理,页面组件无需为每种语法单独写解析器。
草稿和 fixture 的两层过滤
测试文章分为两类。_fixtures/ 下的文件用于排版、媒体和代码回归,在 collection loader 层直接排除;正式目录中的系统测试文章可以保留在集合中,但通过 draft: true 隐藏。公开查询再调用 getPublicPosts():
export async function getPublicPosts() { publicPostsPromise ??= getCollection("posts").then((posts) => // loader 负责隔离 fixture,这一层继续过滤草稿文章。 posts.filter( (post) => post.data.draft !== true && !isFixtureContentId(post.id), ), ); return [...(await publicPostsPromise)];}这两层过滤解决了不同问题:loader 防止回归样本进入生产集合,draft 允许文章文件存在但暂时不公开。文章列表为空时,应依次检查文件扩展名、Frontmatter 是否解析成功、是否误设 draft: true、是否放进 _fixtures,再检查部署使用的内容版本。
内容查询和缓存
getPublicPosts()、getPublicMoments() 和 getPublicAlbums() 使用模块级 Promise 缓存,避免同一次构建或页面生成过程中反复读取集合。函数返回新数组,调用方可以排序而不改变缓存中的原始数组。新增过滤条件时,应保持这个特性,否则一个页面的排序可能影响另一个页面。
文章字段中的 category 和 tags 会经过 taxonomy 工具规范化,确保不同大小写或空白不会生成重复分类。修改 taxonomy 规则会影响分类页、标签页、归档和搜索,必须运行相关内容检查。
从集合到正文
详情页根据 slug 找到 CollectionEntry<"posts">,然后调用 Astro 的 render(entry)。渲染结果包含正文组件和 headings;布局把 headings 交给目录,把 metadata 交给文章 Hero、页脚和 SEO。阅读时间、摘要和 Mermaid 等信息在构建阶段产生,因此浏览器不需要再次解析整篇 Markdown。
文章图片是一个例外:相对路径需要结合 entry 的文件位置解析,交给 src/plugins/article-images/ 和图片工具处理。正文组件只接收已经转换过的图片节点,不应在渲染时拼接磁盘路径。
新增字段的正确步骤
新增一个 Frontmatter 字段时,先修改 posts Schema,再更新已有文章和 CollectionEntry 的使用位置;如果后台需要编辑,再修改 WriteEditor.svelte、adminContent.ts 和对应验证脚本;如果字段影响卡片或 SEO,再修改 PostCard.astro、详情页和 JSON-LD。字段只加在 Schema 而不处理消费方,会让它能写但没有任何显示效果。
新增一个内容集合时,需要定义 loader、Schema、导出注册、查询函数和页面路由,并决定是否允许空集合。不要复用文章 Schema 处理结构完全不同的数据,否则后续字段会变成大量可选值,失去校验的价值。
排查命令
# 先验证集合 Schema 和 Astro 模板类型。pnpm check# 检查后台生成的 Frontmatter 与文章草稿隔离。pnpm verify:admin# 通过完整构建验证 Markdown 管线、静态路由、图片和搜索索引。pnpm buildpnpm check 先确认集合和 Astro 类型没有错误;verify:admin 检查后台生成内容是否符合 Frontmatter 契约;完整构建会进一步验证 Markdown 管线、图片、路由、页面生成和搜索索引。文章显示问题应从这些入口逐层定位,不要直接在列表模板中增加绕过过滤的特殊分支。
字段分类和影响面
文章字段可以按影响范围分成四类:
| 类型 | 字段示例 | 主要消费者 |
|---|---|---|
| 路由和可见性 | draft、password | loader、公开查询、详情页 |
| 列表和排序 | published、pinned、order | 列表、归档、上一篇/下一篇 |
| 展示元数据 | title、description、image、coverAlt | 卡片、Hero、SEO、RSS |
| 正文行为 | layout、toc、comment、许可证字段 | 详情布局、目录、评论、页脚 |
新增字段前先确定它属于哪一类。比如把 featured 当成 pinned 的别名,会让排序和首页推荐出现两套含义;更好的做法是明确字段用途,并在查询层建立唯一规则。
Markdown 管线的调试方法
遇到公式、Mermaid、提示框或图片异常时,不要从最终 HTML 猜原因。先确认 remark 阶段是否生成了预期节点,再确认 rehype 阶段是否消费了它。remarkExcerpt 生成摘要,remarkReadingTime 生成阅读时间,rehypeSlug 和 rehypeAutolinkHeadings 生成标题 ID 与锚点;它们的结果会被详情布局、目录和搜索同时使用。调整其中一个插件的输入结构,可能影响多个页面。
MDX 文章可以导入组件,但仍然必须遵守文章集合的 Frontmatter Schema。组件只改变正文渲染,不会绕过 draft、分类或发布日期校验。