Markdown 起步容易,500 篇就难。办法是规则在第 50 篇之前就立好,而不是之后:严格的 frontmatter schema、集中的组件 map、CI 死链检查。这篇给的是规模化时真能让站不烂的那几份配置,并且按 Astro 6(2026 年 3 月 10 日发布,彻底移除了旧的 Content Collections API)更新过。如果你从 2024 年的教程里抄了一份 schema,现在已经构建不过了。
一句话总结
- 要嵌组件就用 MDX;更看重能搬到 CMS 就用纯 Markdown。
- Astro 6 上必须用 Content Layer API:collection 放在
src/content.config.ts,并且要带loader(本地文件用glob())。type: 'content'和老的src/content/config.ts都没了。 - 用 Zod schema 校验每一个 frontmatter 字段,再加一个
schemaVersionliteral,让字段重命名变便宜。 - 渲染用从
astro:content导入的render(entry)——v6 移除了旧的entry.render()实例方法。 - 跑构建期内链检查 + brace 扫描。LLM 协作编辑迟早会把 MDX 写坏,要在 CI 里拦,而不是在线上。
Astro 6 改了什么(以及为什么现在要管)
Astro 6(稳定版,2026 年 3 月 10 日)完成了从 Astro 5 就开始的那次迁移:移除了对旧 content collections 的自动兼容,以及 legacy.collections 这个 flag,没有任何向后兼容兜底。具体来说,老教程里这三样现在构建会直接报错:
- 配置文件从
src/content/config.ts挪到了src/content.config.ts。 type: 'content'(和type: 'data')被删了;每个 collection 都要写loader。- entry 不再有
.render()方法。改成从astro:content导入render(),调用render(entry)。
Astro 6 还砍掉了 Node 18 和 20,你需要 Node 22.12.0 或更高。迁移的回报:Content Layer API 让 Markdown 构建最快提速约 5 倍、MDX 约 2 倍,内存少用 25–50%——一旦页面上了几千页,这就是 90 秒构建和 5 分钟构建的区别。(来源:Astro v6 升级指南。)
Markdown vs MDX:各自能做什么
第一个要决定的事,是作者实际在哪种格式里写。两者看着像,能做的事差别很大。
Markdown(.md) | MDX(.mdx) | |
|---|---|---|
| 内嵌组件 | 不行 | 行(Astro / React / Vue / Svelte island) |
| 搬到 Ghost / WP / Substack | 直接能搬 | 不行,只有 MDX-aware 构建工具吃得了 |
| grep / diff / lint 摩擦 | 低 | 较高(文件里有 JSX) |
| 是否要构建管线 | 不要 | 要 |
| LLM 写坏风险 | 低 | 较高,未转义的 { } 会挂构建 |
| Astro 6 构建速度 | 最快(比 v4 约快 5 倍) | 比 v4 约快 2 倍 |
一句话挑选:只要排版,写 Markdown;要在文里嵌组件(callout、FAQ 块、交互 demo),写 MDX。两种可以混在一个 collection 里——glob() loader 用 **/*.{md,mdx} 一起匹配。
怎么判断你需要正经 schema
- 计划写超过 100 篇。
- 预期 frontmatter 字段会增加或重命名。
- 希望各文章组件(callout、code、FAQ)风格一致。
- 以后可能翻译或重新导出内容。
只要中两条以上,就把内容目录当数据库看,而不是一堆文本文件。下面这份 schema 就是那张表的定义。
开始前准备
- 先定 MDX vs Markdown——schema 和组件都依赖这个。
- 第一天就定 slug 规范:kebab-case、全小写、slug 里不带日期。
- 确认自己在 Astro 6 + Node 22.12+ 上。跑
astro info确认。
实操步骤
- 用 Content Layer API 定严格 frontmatter schema。 放进
src/content.config.ts(注意:不是老的src/content/config.ts):
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const HUBS = ['ai-applications', 'ai-tools', 'indie-dev',
'prompt-library', 'troubleshooting'] as const;
const articles = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/articles' }),
schema: ({ image }) => z.object({
title: z.string().min(8).max(80),
description: z.string().min(80).max(170),
urlSlug: z.string().regex(/^[a-z0-9-]+$/),
category: z.enum(HUBS),
subcategory: z.string().optional(),
tags: z.array(z.string()).max(8),
publishedAt: z.date(),
updatedAt: z.date().optional(),
author: z.string().default('AI Productivity Guide Team'),
featured: z.boolean().default(false),
draft: z.boolean().default(false),
lang: z.enum(['en', 'zh']),
translationKey: z.string(),
primaryKeyword: z.string().optional(),
hero: image().optional(), // image() helper 用于优化
schemaVersion: z.literal(2).default(2),
}),
});
export const collections = { articles };
变的就是 loader: glob(...) 这一行。schemaVersion literal 给将来一定会做的重命名留路。
- MDX 组件集中管理。
src/components/mdx/index.ts:
import Callout from './Callout.astro';
import FAQ from './FAQ.astro';
import VideoEmbed from './VideoEmbed.astro';
import { Image } from 'astro:assets';
export const mdxComponents = {
Callout,
FAQ,
VideoEmbed,
img: Image, // 默认 <img> 全部走优化版本
};
文章 layout 里,用新的顶层 render() 函数渲染(v6 里 entry.render() 方法已经没了):
---
import { render } from 'astro:content';
import { mdxComponents } from '@/components/mdx';
const { Content } = await render(Astro.props.article);
---
<Content components={mdxComponents} />
作者直接写 <Callout type="warn">…</Callout>,不在每个文件里 import。
- 一种 slug 规范并强制执行。 上面 schema 的
^[a-z0-9-]+$已经把My_Article-2024-01.mdx挡了。再加一个 prebuild 校验urlSlug和文件名一致:
// scripts/check-slug-matches-filename.mjs
import { readdirSync, readFileSync } from 'node:fs';
import matter from 'gray-matter';
const dir = 'src/content/articles/zh/indie-dev';
for (const file of readdirSync(dir)) {
const { data } = matter(readFileSync(`${dir}/${file}`, 'utf8'));
const expected = `${data.urlSlug}.mdx`;
if (file !== expected) {
console.error(`MISMATCH: ${file} != ${expected}`); process.exit(1);
}
}
- 图片放
src/assets/,用image()helper 引用。 内容图永远别走/public/...,会丢响应式尺寸、AVIF/WebP 转换和内容哈希:
---
hero: ../../assets/hero.jpg
---
import { Image } from 'astro:assets';
import diagram from '../../assets/diagram.png';
<Image src={diagram} alt="架构图" widths={[400, 800, 1200]} formats={['avif', 'webp']} />
- 构建期内链检查器——死链直接让 build 挂,
[文字](/zh/articles/.../)里的失效引用永远上不了线:
// scripts/check-mdx-links.mjs(节选)
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const known = new Set(/* frontmatter 里所有 slug */);
let failed = false;
function walk(dir) {
for (const f of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, f.name);
if (f.isDirectory()) walk(full);
else if (f.name.endsWith('.mdx')) {
const md = readFileSync(full, 'utf8');
for (const m of md.matchAll(/\]\(\/[a-z]+\/articles\/([a-z0-9-]+)\/\)/g)) {
if (!known.has(m[1])) {
console.error(`BROKEN: ${full} -> ${m[1]}`); failed = true;
}
}
}
}
}
walk('src/content/articles');
if (failed) process.exit(1);
挂到 package.json:
{
"scripts": {
"prebuild": "node scripts/audit-content.mjs && astro check && node scripts/check-mdx-links.mjs"
}
}
- 防 MDX brace 坑。 MDX 会把散文里的
{...}当成 JSX 表达式解析,所以像「把超时设成{value}」这样一句话就会让整个构建挂掉,报Could not parse expression with acorn。LLM 协作编辑反复踩这个坑。在 CI 里扫一遍:代码围栏之外、紧跟字母的左花括号就报:
# 散文里出现未转义 brace 就挂 build
awk 'BEGIN{f=0} /^```/{f=!f; next} {if(!f && /{[a-z]/) print FILENAME":"NR}' \
src/content/articles/**/*.mdx \
| tee /tmp/brace-hits.txt
test ! -s /tmp/brace-hits.txt
内容里的修法是把这个 token 用反引号包起来(`{value}`),或者改用 [value] 占位符。这道守门在上线前拦住,而不是等构建变红才发现。
CONTENT.md写编辑规则。 别让作者通过构建报错倒推你的 schema。把必填字段、slug 规则、组件名、brace 规则写在一页里。
执行检查清单
src/content.config.ts里的 Content Layer collection 校验每个 frontmatter 字段。- 每个 collection 都有
loader(不再有type: 'content')。 - MDX 组件集中管理;文章不在每个文件里 import。
- slug 正则 + 文件名匹配在 prebuild 强制。
- 内链检查接到 CI,死链挂构建。
- brace 扫描守 MDX 坑,避免上线前出错。
- CI 和
.nvmrc都用 Node 22.12+。
上线后验证
- 每个 PR 都过
astro check+ prebuild 脚本。 - 违反 schema 的新文章构建挂掉,Zod 报错清晰、点名字段。
- sitemap 条目与 frontmatter 里声明的 slug 一致。
- 迁移过的文章在 v6 的
render()改动后组件仍正常渲染。
容易踩的坑
- 从老教程抄
type: 'content'schema——在 Astro 6 上构建不过。 - 配置还留在
src/content/config.ts;Astro 6 只读src/content.config.ts。 - layout 里还在调
entry.render()——换成从astro:content导入的render(entry)。 - 让每篇 MDX 自己 import 组件,每篇样式都微妙不同。
- 图片硬写
/public/...路径,响应式优化全失效。 - frontmatter schema 不做版本管理——改字段名一半文章静默失败。
schemaVersionliteral 是最便宜的防线。 - 跳过 brace 扫描——LLM 协作编辑迟早会因为一个未转义的
{让构建挂掉。
FAQ
- MDX 还是 plain Markdown: 要嵌组件或交互就 MDX;要可移植性和 CMS 兼容性就 plain Markdown。两者可以放在同一个 collection 里。
- 必须从
type: 'content'迁出来吗: 是的,只要你在 Astro 6(2026 年 3 月及以后)。旧 API 和legacy.collectionsflag 都被移除了,没有兜底。把配置挪到src/content.config.ts、加glob()loader、改用render(entry)。 - 图片要不要进 git: 关键插图放
src/assets/进 git,重媒体用 CDN。提交的资源让构建可复现,也让image()helper 能给它们做哈希。 - 多人协作怎么保持 frontmatter 一致: schema 强制 +
CONTENT.md文档。过不了astro check的 PR 直接拒。 - 国际化怎么做: 用平行目录如
src/content/articles/en/和src/content/articles/zh/,共享同一 schema,用translationKey字段把译文配对。 - frontmatter 字段重命名怎么迁移: bump
schemaVersion,在scripts/写一次性迁移脚本,跑完一个 PR 提交。