第 05 / 11 章

MDX 编译链与代码围栏

从 MdxContent、rehype 合并高亮,到 Prism 语言表与站点日期常量——散篇与系列章节共用的服务端正文管线。

⏱ 2 分钟730 字

散篇与系列章节在索引层分开,但正文编译只有这一条管线。章节页同样调用 MdxContent。

lib/mdx.tsx:正文如何变成 React§

import "server-only";
import { MDXRemote } from "next-mdx-remote/rsc";
// ...

export function MdxContent({ source }: { source: string }) {
  return (
    <MDXRemote
      source={source}
      components={mdxComponents}
      options={{
        mdxOptions: {
          format: "mdx",
          remarkPlugins: [remarkGfm],
          rehypePlugins: [
            [rehypeSlug, { prefix: "" }],
            rehypeCodeGroups,
            rehypePrism,
          ],
        },
      }}
    />
  );
}

要点:

  1. import "server-only"——若被客户端组件误引用,构建会直接报错,防止把编译链打进浏览器包。
  2. 插件顺序固定:先 GFM(表格等)→ 标题加 id → 合并代码组 → Prism 高亮。顺序反了,高亮或合并会作用在错误的树上。
  3. mdxComponents 来自 components/mdx/index.tsx:把 Markdown 的 pre、a、h2 等换成站点自己的组件;并导出 MDX 里可直接写的 <Callout />。

类型上有两处 as unknown as ...:unified 插件类型与 next-mdx-remote 的组件表类型不完全对齐,属于边界强转,不是业务分支。

散篇与系列章节共用同一个 MdxContent——小册不另开编译器。系列章节页同样调用 <MdxContent source={chapter.content} />。

lib/rehype.ts:作者写法 → DOM 约定§

作者在 MDX 里这样写:

```tsx group="tabs" file="Tabs.tsx"
// ...
```
```css group="tabs" file="style.css"
/* ... */
```

浏览器里最终需要的是一个代码块组件,带着多个文件的原文,而不是两个互不相干的 <pre>。rehypeCodeGroups 就做这件事;随后 rehypePrism 给每个围栏内的 <code> 写入本地 Prism 高亮。

本章只记三点,便于串管线:

  1. meta:group / file / preview(或 live)写在围栏语言后面;
  2. 合并:相邻且同 group 的 <pre> 收成一个,并挂上 data-files(未高亮原文);
  3. 高亮:再遍历 <pre><code>,把 Prism HTML 解析回 hast 子节点——失败则保持原文,不走 CDN。

parseMeta 的 token 规则、同层扫描状态机、双轨契约与端到端示例,见下一章「rehype 源码」。

lib/prism.ts 与 lib/code-lang.ts§

code-lang.ts 维护语言别名(ts→typescript,html→markup 等),并声明:

export const PREVIEWABLE = new Set(["markup", "css", "javascript"]);

只有这三类能进沙箱 iframe。TS/TSX 没有浏览器内置运行时,因此即使写了 preview 也不会生成可运行预览文档。

prism.ts 用副作用 import "prismjs/components/prism-..." 注册语法,再:

export function highlight(code: string, lang: string | null | undefined): string {
  const name = resolveLang(lang);
  const grammar = Prism.languages[name];
  if (!grammar) return code;
  try {
    return Prism.highlight(code, grammar, name);
  } catch {
    return code;
  }
}

lib/site.ts:站点常量§

export const site = {
  name: "墨栈",
  latinName: "INKSTACK",
  tagline: "把思考写成代码,把代码写成文章。",
  description: "……",
} as const;

export function formatDate(iso: string): string {
  const d = new Date(iso + "T00:00:00+08:00");
  return new Intl.DateTimeFormat("zh-CN", { … }).format(d);
}

日期字符串按东八区零点解析,避免服务器在 UTC 时把 2026-10-01 显示成前一天。首页、文章页、卡片共用 formatDate,避免各写一套。

下一章细拆 rehypeCodeGroups 与 rehypePrism;再往后才进入 app/ 路由组装。