第 06 / 11 章

rehype 源码:代码组合并与离线高亮

逐段拆解 rehypeCodeGroups 与 rehypePrism:meta 语法、同层扫描合并、data-files 双轨契约,以及 Prism HTML 写回 HAST 的完整路径。

⏱ 6 分钟1828 字

上一章把 lib/mdx.tsx 的插件顺序讲清楚了:先 slug,再合并代码组,再 Prism。本章只盯 lib/rehype.ts 里两个自定义插件——它们把「作者在围栏上写的 meta」翻译成「CodeBlock 能消费的 DOM 契约」。

读完应能回答:

  1. 为什么 data-files 存原文,而高亮结果另放在子节点里;
  2. 同名 group 在什么条件下会合并、什么条件下不会;
  3. 为何必须先 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:同层扫描合并§

总结构§

  1. walk(tree):对每个 Root | Element,先用 process 重写当前层 children,再对子元素递归。
  2. 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);
}

要点:

  1. 只处理 <pre> 内的 <code>——行内 `code` 不会被 Prism 拆开。
  2. 去掉尾随换行再高亮——围栏源码末尾常见空行,不然行号会多一截。
  3. highlight(lib/prism.ts)本地 Prism:别名解析 → 语法表 → HTML 字符串;无语法或抛错则原样返回。
  4. hast-util-from-html 的 fragment: true 把 HTML 片段解析成子节点写回——不改写 data-files。

因此双轨在高亮步骤之后仍然成立:属性里是源码,子树里是着色 DOM。

插件顺序为何不能反§

顺序结果
Groups → Prism(现行)先定契约与多个 <code>,再分别上色
Prism → GroupscodeText 多半仍能从 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.tshighlight、PREVIEWABLE
映射 precomponents/mdx/index.tsxpre → CodeBlock
消费契约components/mdx/code-block.tsx解析 data-*,Tab / 复制 / iframe

改「作者能写哪些 meta」→ 动 parseMeta;改「合并规则」→ 动 process 循环;改「长什么样」→ 动 CodeBlock 与 CSS,不必回头改 Prism 写回逻辑。


下一章是扩展篇:用一份最简 MDX,把进入 rehypeCodeGroups 时的整棵 tree 摊开,再按 i / k / out 逐步跑完。随后才回到主线看 app/ 路由。