Markdown 起步容易,500 篇就难。办法是规则在第 50 篇之前就立好——严格的 frontmatter schema、集中的组件 map、CI 死链检查。下面这套配置就是能在规模化时真的让站不烂的那一套。
问题背景
几乎所有 Astro 内容站最后都会撞同样的问题:frontmatter 不一致、slug 冲突、组件每篇用法不同、图片路径乱飘。这不是 Markdown 的问题,是流程的问题。用代码强制的几条轻量约定能避掉大半。
Markdown vs MDX:各自能做什么
第一个要决定的事是作者实际在哪种格式里写。两者看起来像,能做的事差别很大。
Markdown
纯文本。标题、列表、链接、代码块、图片,就是这些。
- 优点: 哪里都能搬——Ghost、WordPress、Substack、Notion 都能直接吃。git 里好 grep、好 diff、好 merge。不渲染也能当纯文本读。LLM 很少把它写坏。
- 缺点: 没有组件、没有内嵌交互、没有逻辑。超出排版范围的东西都得放在文件外。
MDX
Markdown 加上 JSX、imports 和组件。可以把 React(或你框架对应的)组件直接嵌进文章里。
- 优点: call-out、图表、交互嵌入、自定义 shortcode、把设计系统直接复用到内容里。让内容层和站点其他部分共享同一套视觉语言。
- 缺点: 不能搬到普通 CMS——只有 MDX-aware 的构建工具才吃得了。grep 和 lint 都更难。必须有构建管线。LLM 偶尔会把 brace 转义写错,直接让构建挂掉(本站就反复中过这个招——见仓库里的
fix-mdx-braces.mjs)。
一句话挑选
你需要的只是 typography,用 Markdown;需要在文里嵌组件,用 MDX。
判断标准
- 计划写超过 100 篇。
- 预期字段会增加或重命名。
- 希望各文章组件(callout、code、FAQ)风格一致。
- 以后可能翻译或重新导出内容。
快速结论
要用组件就选 MDX;只写纯文、要最大可移植性,选 plain Markdown。
开始前准备
- 先定 MDX vs MD——schema 和组件都依赖这个。
- 第一天就定 slug 规范(kebab-case、不带日期)。
- 用 Astro 的话先打开 Content Collections。
实操步骤
- 用 Content Collections 定严格 frontmatter schema。
src/content/config.ts:
import { defineCollection, z } from 'astro:content';
const HUBS = ['ai-applications', 'ai-tools', 'indie-dev',
'prompt-library', 'troubleshooting'] as const;
export const collections = {
articles: defineCollection({
type: 'content',
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),
}),
}),
};
schemaVersion 给将来一定会做的重命名留路。
- 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 里:
---
import { mdxComponents } from '@/components/mdx';
const { Content } = await Astro.props.article.render();
---
<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';
for (const file of readdirSync('src/content/articles/zh/indie-dev')) {
const { data } = matter(readFileSync(`src/content/articles/zh/indie-dev/${file}`, 'utf8'));
const expected = `${data.urlSlug}.mdx`;
if (file !== expected) {
console.error(`MISMATCH: ${file} ≠ ${expected}`); process.exit(1);
}
}
- 图片放
src/assets/,用image()helper 引用。 内容图永远别走/public/...,会丢响应式优化:
---
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 挂:
// scripts/check-mdx-links.mjs(节选)
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const known = new Set(/* 所有 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 坑。 LLM 喜欢在散文里写
{var},MDX 会当 JSX 解析。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
CONTENT.md写编辑规则。 别让作者通过 error message 倒推你的 schema。
执行检查清单
- Content Collections schema 校验每个 frontmatter 字段。
- MDX 组件集中管理;文章不在每个文件里 import。
- slug 正则 + 文件名匹配在 prebuild 强制。
- 内链检查接到 CI,死链挂构建。
- brace 扫描守 MDX 坑,避免上线前出错。
上线后验证
- 每个 PR 都过
astro check+ prebuild。 - 违反 schema 的新文章构建挂掉,error 清晰。
- sitemap 条目与 frontmatter 里声明的 slug 一致。
容易踩的坑
- 让每篇 MDX 自己 import 组件——每篇样式都微妙不同。
- 图片硬写
/public/...路径,响应式优化全失效。 - 什么都用纯
.md,错过 MDX 嵌入结构化 FAQ 或表格的能力。 - frontmatter schema 不做版本管理——改字段名一半文章静默失败。上面那个
schemaVersionliteral 是最便宜的防线。 - 把 Markdown 当成”想写啥写啥”,而不是内容数据库。
- 没装 brace 扫描守门——LLM 协作编辑迟早会让 build 挂。
FAQ
- MDX 还是 plain Markdown: 要嵌组件或交互就 MDX;要可移植性和 CMS 兼容性就 plain Markdown。
- 图片要不要进 git: 关键插图进 git,重媒体用 CDN。无损 commit 让构建可复现。
- 多人协作怎么保持 frontmatter 一致: schema 强制 +
CONTENT.md文档。违反 schema 的 PR 直接拒。 - 国际化怎么做: 用平行目录如
src/content/articles/en/和src/content/articles/zh/,共享同一 schema,加translationKey字段。 - frontmatter 字段重命名怎么迁移?: bump
schemaVersion,在scripts/写一次性迁移脚本,跑完一个 PR 提交。