上一章把 lib/mdx.tsx 的插件顺序讲清楚了:先 slug,再合并代码组,再 Prism。本章只盯 lib/rehype.ts 里两个自定义插件——它们把「作者在围栏上写的 meta」翻译成「CodeBlock 能消费的 DOM 契约」。
读完应能回答:
- 为什么
data-files存原文,而高亮结果另放在子节点里; - 同名
group在什么条件下会合并、什么条件下不会; - 为何必须先
rehypeCodeGroups、后rehypePrism。
客户端 Tab / 复制 / 沙箱见后文「代码块与 MDX 组件」;本章停在服务端 AST。
设计目标:一份契约,两种数据§
浏览器最终只要一个 <pre>,由 components/mdx/index.tsx 映射成 CodeBlock。这个 <pre> 需要同时满足:
| 需求 | 数据放哪 |
|---|---|
| 多文件 Tab 标签名、语言 | data-files JSON 的 name / lang |
| 复制、沙箱拼文档 | data-files 里的 未高亮 code |
| 屏幕上的语法着色 | <pre> 下各个 <code> 的子节点(Prism 产出的 span 树) |
| 是否尝试预览 | data-preview="true"(组内任一围栏写了 preview / live 即可) |
| 组身份(调试 / 扩展) | data-group |
这就是双轨:原文走属性,展示走子树。插件不依赖 React,只改 HAST;交互全部留给客户端。
unified 插件形态是工厂函数:
export function rehypeCodeGroups() {
return function (tree: Root) { /* 改树 */ };
}外层可收 options(这里没有);内层拿到整棵 Root。
共享工具:meta、语言、纯文本§
两个导出函数都依赖同文件里的小工具。先建立词汇表。
parseMeta:围栏第一行语言后面的字符串§
作者写:
```tsx group="demo" file="App.tsx" preview
```code.data.meta 大致是 group="demo" file="App.tsx" preview。parseMeta 用正则按空白(尊重引号)切 token,再填:
{ group: string | null; file: string | null; preview: boolean; title: string | null }规则:
key=value→ 识别group、file/filename、title;值可带双引号- 裸 token
preview或live→preview: true - 带扩展名的裸 token(如
App.tsx)→ 当作file(未显式写file=时的快捷写法) - 若始终没有
file,用title回退
metaOf(code) 只是安全地取出 code.data.meta 再交给 parseMeta。
langOf / codeText§
langOf:从className里找language-xxx,小写返回;没有则"text"codeText:递归拼接元素下所有文本(高亮前是整段源码;高亮后也能从 span 拼回,但本章管线保证合并发生在高亮前)
decoratePre:契约写入点§
pre.properties = {
...原有属性,
"data-files": JSON.stringify(files),
...(group ? { "data-group": group } : {}),
...(preview ? { "data-preview": "true" } : {}),
};
pre.children = codes;单文件与多文件组最终都走这里——CodeBlock 不必分两套解析入口。
rehypeCodeGroups:同层扫描合并§
总结构§
walk(tree):对每个Root | Element,先用process重写当前层children,再对子元素递归。process(nodes):从左到右扫同层节点,产出新数组(不在原数组上 splice,避免索引错乱)。
blockquote、列表项里的相邻围栏也能合并,因为会走进子树。
process 状态机(按分支)§
对每个 nodes[i]:
① 不是 <pre>
原样 out.push,i++。段落、标题不参与。
② 是 <pre> 但找不到元素子节点
防御性原样输出(畸形树不崩)。
③ 有 <code>,读出 meta0 与 file0
const file0 = {
name: meta0.file,
lang: langOf(code0),
code: codeText(code0),
};④ meta0.group 为空——单文件路径
仍调用 decoratePre,data-files 为单元素数组;有 preview 则挂 data-preview。不向后看邻居。
⑤ 有 group——向后吞并
初始化:
pres = [当前 pre]files = [file0]codes = [](稍后装填)preview = meta0.preview- 从
k = i + 1继续扫
循环里:
| 条件 | 动作 |
|---|---|
| 空白文本节点 | continue(跳过,不结束组) |
不是 <pre> | break(组结束) |
<pre> 无 code / meta 的 group 不同 | break |
同 group | 推进 pres / files;若该块 preview 则整组 preview = true |
组结束后,再把紧跟的空白文本一并吃掉(end),避免合并后留下多余空白节点。
然后把每个被吞 pre 里的元素子节点(即各个 <code>)推进 codes,并 delete child.data——清掉挂在 code 上的 meta,减少无意义数据进客户端序列化。最后:
decoratePre(node, codes, files, { group, preview });
out.push(node); // 只保留第一个 pre
i = end; // 跳过已吞的兄弟 pre 与尾随空白其余 <pre> 不会进入 out,等于从树上消失。
合并条件一句话§
同一父节点下、相邻(中间只允许空白)、group 字符串全等的围栏,才会合成一块。
同名但中间夹了说明段落 → 两块独立 <pre>,各自带自己的 data-files。
rehypePrism:只给围栏上色§
合并完成后,树上已是「一个 pre、零到多个 code、属性里已有原文」。rehypePrism 再走一遍树:
if (child.tagName === "pre") {
for (const c of child.children) {
if (c.tagName !== "code") continue;
const lang = langOf(c);
const raw = codeText(c);
if (!raw) continue;
const stripped = raw.replace(/\n+$/, "");
const html = highlight(stripped, lang);
if (html === stripped) continue; // 未知语言或失败:保持原文
const frag = fromHtml(html, { fragment: true });
c.children = frag.children;
}
} else {
walk(child);
}要点:
- 只处理
<pre>内的<code>——行内`code`不会被 Prism 拆开。 - 去掉尾随换行再高亮——围栏源码末尾常见空行,不然行号会多一截。
highlight(lib/prism.ts)本地 Prism:别名解析 → 语法表 → HTML 字符串;无语法或抛错则原样返回。hast-util-from-html的fragment: true把 HTML 片段解析成子节点写回——不改写data-files。
因此双轨在高亮步骤之后仍然成立:属性里是源码,子树里是着色 DOM。
插件顺序为何不能反§
| 顺序 | 结果 |
|---|---|
| Groups → Prism(现行) | 先定契约与多个 <code>,再分别上色 |
| Prism → Groups | codeText 多半仍能从 span 拼回原文,合并「碰巧」能工作,但职责颠倒:先美化再拼契约,心智与调试成本更高 |
现行顺序的语义是:先翻译作者意图,再装饰展示树。
端到端示例§
单文件 + preview§
作者:
```html file="index.html" preview
<button>Hi</button>
```rehypeCodeGroups 之后(概念结构):
<pre
data-files='[{"name":"index.html","lang":"html","code":"<button>Hi</button>\n"}]'
data-preview="true"
>
<code class="language-html">…纯文本…</code>
</pre>rehypePrism 之后:<code> 内变为 token span;data-files 不变。CodeBlock 见 data-preview 且语言在 PREVIEWABLE 内,才拼沙箱文档。
同组多文件§
作者:
```tsx group="counter" file="App.tsx"
export function App() {
return <button>1</button>;
}
```
```css group="counter" file="style.css"
button { color: tomato; }
```合并后一个 <pre data-group="counter" data-files='[…]'>,下挂两个 <code>(tsx / css)。高亮后各 code 自带 span;客户端用 files[i] 与 children[i] 对齐 Tab。
组断开(同名也不合并)§
```js group="a" file="a.js"
1
```
中间一段说明。
```js group="a" file="b.js"
2
```中间非空白节点打断扫描 → 两个独立代码块。group 不是全局命名空间,只是相邻合并钥匙。
与下游的接缝§
| 阶段 | 文件 | 职责 |
|---|---|---|
| 挂插件 | lib/mdx.tsx | 顺序:slug → CodeGroups → Prism |
| 写契约 | lib/rehype.ts | 本章 |
| 语言别名 / 可预览集 | lib/code-lang.ts、lib/prism.ts | highlight、PREVIEWABLE |
映射 pre | components/mdx/index.tsx | pre → CodeBlock |
| 消费契约 | components/mdx/code-block.tsx | 解析 data-*,Tab / 复制 / iframe |
改「作者能写哪些 meta」→ 动 parseMeta;改「合并规则」→ 动 process 循环;改「长什么样」→ 动 CodeBlock 与 CSS,不必回头改 Prism 写回逻辑。
下一章是扩展篇:用一份最简 MDX,把进入 rehypeCodeGroups 时的整棵 tree 摊开,再按 i / k / out 逐步跑完。随后才回到主线看 app/ 路由。