Markdown / MDX 内容站搭建

搭一个能从 50 篇撑到 1000 篇都不重构的 Markdown / MDX 内容站,附 Content Collections schema、组件 map、内链检查脚本。

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。

实操步骤

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

  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 里:

---
import { mdxComponents } from '@/components/mdx';
const { Content } = await Astro.props.article.render();
---
<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';
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);
  }
}
  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']} />
  1. 构建期内链检查器——死链直接让 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"
  }
}
  1. 防 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
  1. 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 不做版本管理——改字段名一半文章静默失败。上面那个 schemaVersion literal 是最便宜的防线。
  • 把 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 提交。

相关阅读

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