Markdown / MDX 内容站搭建

在 Astro 6 上搭一个能从 50 篇撑到 1000+ 篇都不重构的 Markdown / MDX 内容站,附 Content Layer schema、组件 map、内链检查和 brace 坑。

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 字段,再加一个 schemaVersion literal,让字段重命名变便宜。
  • 渲染用从 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(.mdMDX(.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 确认。

实操步骤

  1. 用 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 给将来一定会做的重命名留路。

  1. 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。

  1. 一种 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);
  }
}
  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']} />
  1. 构建期内链检查器——死链直接让 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"
  }
}
  1. 防 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] 占位符。这道守门在上线前拦住,而不是等构建变红才发现。

  1. 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 不做版本管理——改字段名一半文章静默失败。schemaVersion literal 是最便宜的防线。
  • 跳过 brace 扫描——LLM 协作编辑迟早会因为一个未转义的 { 让构建挂掉。

FAQ

  • MDX 还是 plain Markdown: 要嵌组件或交互就 MDX;要可移植性和 CMS 兼容性就 plain Markdown。两者可以放在同一个 collection 里。
  • 必须从 type: 'content' 迁出来吗: 是的,只要你在 Astro 6(2026 年 3 月及以后)。旧 API 和 legacy.collections flag 都被移除了,没有兜底。把配置挪到 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 提交。

相关阅读

标签: #独立开发 #Astro #MDX #Content Collections #内容运营