Last updated on

如何编写 unified 插件 (2) remark

AI 生成
接着上一篇,讲一讲 unified 生态,以及如何使用 enter & exit 方式来编写 remark 插件

回顾

前面提到了用 visit 方法来修改节点树,从而编写一个 remark 插件。本文将从之前所述的另一种方法,来阐述一下 micromark 生态以及用这一个模块来编写插件的方法。

以下为 Unified 中处理 markdown 的一个整个流程,一个 processor 中包含 parser, transformer(s), compiler.

| ........................ process ........................... |
| .......... parse ... | ... run ... | ... stringify ..........|
+--------+ +----------+
Input ->- | Parser | ->- Syntax Tree ->- | Compiler | ->- Output
+--------+ | +----------+
X
|
+--------------+
| Transformers |
+--------------+

对于使用下面的语句来进行 markdown to HTML 的转换的过程

unified
.use(remarkParse)
.use(remarkPlugin) // remark 插件
.use(remarkRehype)
.use(rehypePlugin) // rehype 插件
.use(rehypeStringify)
.processSync('# Hello world')
  • remarkParse 充当 parser 的身份,将文本转换为 AST
  • rehypeStringify 充当 compiler 的身份,将 AST 转换为字符串
  • 其他插件均可认为是 transformer

从 RK 处得到的帮助

我在 Bing 上搜索 remark-wikilink,偶然中发现了一篇最近几个月前才写的博客1。这篇文章介绍了一些 unified 的生态,也为我理解这一系列内容提供了一些帮助。在阅读了 Ta 编写的 remark-callout 插件后,感觉这位作者对这一生态是比较了解的,故通过邮件,得到了以下参考:

  • Mdast is the AST of markdown, write a parser plugin that generates mdast.
  • You should know micromark. Creating a micromark extension is of great help.
  • After the micromark plugin generates tokens, mdast-util can transform tokens into your customized mdast nodes by adding extra attributes to your nodes.
  • Micromark common state machine may be helpful when you are implementing your own state machine.

Unified 生态

完整的生态建议查看 unified 官方 README

由 unified 主导的组织有

  • remark 能够对 markdown 和 mdast 进行监测和修改,主导了 remark 插件
  • syntax-tree 为语法分析树奠定基础,定义了 unist,并衍生出 mdast, hast, nlcst, esast 等。这个系列还包含了 mdast-util 和 hast-util
  • rehype 能够对 HTML 和 hast 进行监测和修改,主导了 rehype 插件
  • retext 对英文自然语言进行一些分析
  • micromark 最小的通用 markdown 编译器,是 unified 新的基础

在 micromark 出现后,我们依然可以使用 unist-util-visit 来对语法树进行修改,但是使用基于 micromark 的插件也拥有其优势。在代码结构上,虽然看起来更加复杂了,但是能够从 enter & exit 的角度,更加细粒度地修改语法树。同时,在阅读其源码中,也发现了 toMarkdown 和 fromMarkdown 的区分,使其能够从 AST 恢复到 markdown 的原始样貌。

调用关系

如果参考 remark-gfm 插件,会发现这里面引用的关系有点过于复杂。所以还是从一个稍微简单一点的 remark-math 中来看这几位之间的关系吧。

  • remark-math
    • 引用了 mdast-util-math 中的 mathFromMarkDown 和 mathToMarkdown 方法
    • 引用了 micromark-extension-math 的 math
    • 将这些程序分别加入到 remark 程序中的 fromMarkdownExtensions, toMarkdownExtensions, micromarkExtensions 中
  • mdast-util-math
    • fromMarkdown 返回了一个对象,其中包含了 enter 和 exit 对象
      • enter 包含了进入某类节点的处理函数,过程中会指定对应语法树节点的一些属性
      • exit 包含了退出某类节点的处理函数,当退出该节点时,在函数的成员方法中可以获取到节点内容的值
    • toMarkdown 返回一个对象,其中包含了 unsafe 和 handlers 属性
      • unsafe 包含了转换回 markdown 所需的字符和所属的阶段
      • handlers 包含了转换回 markdown 的函数处理
    • index.d.ts 类型说明文件中,指定了新规则的数据类型
  • micromark-extension-math
    • syntax.js 指定了从 markdown 转换为 AST 的函数
    • html.js 指定了从 AST 转换为 HTML 的函数
    • index.d.ts 指定了新的 TokenTypeMap

Micromark2

Micromark 可以提供直接从 markdown 转换为 HTML 的功能,也可以获取每个 token 的详细信息,做细粒度的操作。作者说 micromark 是一个比较新的库,而且整个包很小,在 Common Mark 标准和包大小的层面上拥有优势。

Remark 是建立在 micromark 和 AST 上的,remark 支持所有 micromark 的扩展,因此有很多基于 micromark 的插件,都会包装到 remark 上。

Micromark 的插件包含 Syntax Extension 和 Html Extension 两类,前者指定了 markdown 如何解析,后者指定了最终编译为什么样的 HTML.

编写 remark 插件的其他思路 · remark-directive

同时,micromark 的主页建议我们,对于一些有特殊要求的,建议通过 remark-directive 插件来实现,从而避免从头开始写一个插件。

remark-directive 是通过识别 :name[attribute] 的格式,然后自定义的编写一个非常简单的,通过 visit 来修改语法树的 remark 插件,来满足需求。例如我们需要将 :ico[vscode-icons:file-type-js-official] 翻译为 iconify 的图标

关于如何使用 visit 来编写插件,remark-directive 插件的主页有编写指南,也可以参考笔者的上一篇博客。

如果读者的需求比较简单,则本文阅读到此处即可。但我相信依然有硬着头皮继续研究的开发者,想要一探到底,那么我们继续。

在 create a micromark extension 部分,需要有以下的预备知识

  • 相当熟悉 JavaScript
  • 阅读 unified 的 README 到 API 部分前,以便能够更好地理解 micromark 适用于哪里
  • 阅读 micromark#Architecture 部分,以理解 micromark 如何运作
  • 阅读 micromark#Extending-markdown 部分,以确定扩展 markdown 的语法是否有意义

mdast-util

这部分的导出结构一般为

export function fromMarkdown() {
return {
enter: { ... },
exit: { ... },
}
}
export function toMarkdown() {
return {
unsafe: [ ... ],
handlers: { ... },
}
}

这两个函数一个负责「读」,一个负责「写」,合起来才算一个完整的语法扩展。

fromMarkdown:把 token 变成节点

micromark 只负责把 markdown 切成一个个 token,真正把 token 组装成 mdast 节点的是 mdast-util-from-markdown。而 fromMarkdown() 返回的 enter / exit 就是挂给它的钩子:

  • enter 在某种 token 开始时触发,通常在里面用 this.enter(node, token) 创建并压入一个新节点
  • exit 在该 token 结束时触发,此时用 this.exit(token) 弹出节点,并且可以用 this.sliceSerialize(token) 拿到 token 的原始文本

以 remark-math 为例,遇到 mathFlow token 时,enter 里创建一个 { type: 'math' } 节点,exit 里再把公式内容写进节点的 value 字段。插件跑完,语法树里就多了一种 math 节点,下游的 rehype 插件才有东西可认。

toMarkdown:把节点写回 markdown

toMarkdown() 返回两个东西:

  • handlers:每种自定义节点该怎么序列化回 markdown。比如 math 节点要输出成 $$ ... $$
  • unsafe:声明哪些字符序列在输出时是「危险」的,需要转义,否则生成的 markdown 会被重新解析成别的结构

这一步经常被忽略,因为大多数人只关心 md → html。但只要你的处理器还需要 html → md,或者要对 markdown 做 round-trip(格式化、批量迁移、自动修复),没有 toMarkdown 就意味着自定义节点在写回时会被整个丢掉。

这也是为什么 unified 生态里的插件普遍同时提供 fromMarkdown 和 toMarkdown——它是这套体系「双向可逆」要付的代价。

remark

在 wooorm 维护的插件中,通常是将上述声明的 micromark 插件和 mdast-util 都 push 到插件列表中。

这里的「push 到插件列表」其实是在往 processor 的 data 里登记,remark-math 的实现就很典型:

export function remarkMath(options) {
const data = this.data()
const add = (field: string, value: unknown) => {
if (data[field]) data[field].push(value)
else data[field] = [value]
}
add('micromarkExtensions', math(options))
add('fromMarkdownExtensions', mathFromMarkdown())
add('toMarkdownExtensions', mathToMarkdown(options))
}

三个字段分别对应三个下游消费者:

字段谁来读作用
micromarkExtensionsremark-parse告诉 micromark 怎么切 token
fromMarkdownExtensionsmdast-util-from-markdown告诉它 token 怎么变成节点
toMarkdownExtensionsremark-stringify告诉它节点怎么写回 markdown

所以一个「完整」的 remark 插件看起来只有十几行,是因为真正干活的逻辑都在 micromark 和 mdast-util 那两个包里,插件本身只是个登记处。理解了这一点,再去看 remark-gfm 那种引用关系复杂的插件,就不会觉得无从下手了——它无非是把更多的 construct 和 handler 塞进了同样的三个数组。

如何编写 remark 插件

前面几节讲的都是「生态长什么样」。但真正坐下来写的时候,第一个问题永远是:从哪个文件开始。

这一节按实际动手的顺序走一遍:定位 → tokenize → resolve → mdast-util → 类型 → 装配。最后拿本站的 wikilink 插件当完整示例——它就是照这个流程写的。

第 1 步:定位 —— construct 挂在哪一层

micromark 的扩展点本质上是几张「首字符 → construct」的映射表。原生语法在 constructs.js 里的注册就是最好的模板:

// micromark/dev/lib/constructs.js(节选)
export const document = {
[codes.greaterThan]: blockQuote,
[codes.dash]: list,
// ...
}
export const flow = {
[codes.numberSign]: headingAtx,
[codes.dash]: [setextUnderline, thematicBreak],
[codes.graveAccent]: codeFenced,
[codes.tilde]: codeFenced
}
export const text = {
[codes.asterisk]: attention,
[codes.lessThan]: [autolink, htmlText],
[codes.leftSquareBracket]: labelStartLink,
[codes.backslash]: [hardBreakEscape, characterEscape]
}
export const insideSpan = { null: [attention, resolveText] }
export const attentionMarkers = { null: [codes.asterisk, codes.underscore] }

从这张表能读出三条规则:

  • 键是字符码,不是字符。codes.tilde 就是 ~ 的 charCode。
  • 值可以是数组,按顺序试,前一个 nok 才轮得到后一个。~ 在 flow 里是 codeFenced,在 text 里被删除线扩展占用,靠的就是分层互不干扰。
  • null 作键表示「不按首字符挑」,整段内容都会跑一遍。insideSpan 和 attentionMarkers 都是这种。

至于挂哪张表,看语法的粒度:

层级何时触发典型语法
document整篇文档,可嵌套其他容器列表、引用块
flow每行行首标题、围栏代码、分割线
text行内任意位置强调、行内代码、链接
string只处理字符串型 token 内部字符转义、HTML 实体
insideSpan行内内容解析完成后强调配对、删除线配对

$$...$$ 这种独占一行的公式挂在 flow,![[...]] 这种行内嵌入挂在 text。挂错位置的话 tokenize 压根不会被调用——这是新手最常见的「写完了但没反应」。

第 2 步:tokenize —— 手写一个状态机

一个 construct 至少要有 tokenize,其余字段按需:

const construct = {
tokenize, // 必填:状态机入口
name: 'mathFlow', // 可选:便于按名字 disable
concrete: true, // 可选:不允许被其他容器打断
resolve, // 可选:解析后修正 token
resolveAll, // 可选:整段内容解析完后统一修正
partial: true, // 可选:只能被别的 construct 调用,不能单独注册
}

tokenize 的签名是 (effects, ok, nok) => State。它自己不被逐个字符调用,只负责返回第一个状态函数。之后 micromark 把字符一个个喂进去:

function tokenize(effects, ok, nok) {
return start
function start(code) {
// code 是当前字符的字符码
}
}

每个 State 收到一个 code,只能做三件事之一:

  1. return 下一个状态函数 —— 继续
  2. return ok(code) —— 成功,从当前位置交还给上层
  3. return nok(code) —— 失败,回滚 tokenize 开始后的所有 token,从当前位置换别的 construct 再试

code 有几个特殊值:null 是文件结束,codes.lineFeed 这类是负数(虚拟字符,不对应原文),普通字符就是正常的 charCode。

effects 一共六个方法:

方法作用
enter(type, fields?)压入一个 token,fields 可挂自定义字段
exit(type)弹出 token
consume(code)吃掉当前字符并前进
attempt(c, ok, nok)尝试子 construct,失败则回滚
check(c, ok, nok)只做前瞻,无论成败都回滚
interrupt(c, ok, nok)内容行之后的打断检查

关键约束:enter 和 exit 必须严格配对。token 的嵌套关系会原封不动传给 mdast-util 的 enter / exit,配不平的话症状是节点内容整体错位,而且很难查。

拿 math 的围栏公式看,start 只做三件事:

function start(code) {
assert(code === codes.dollarSign, 'expected `$`')
effects.enter('mathFlow')
effects.enter('mathFlowFence')
effects.enter('mathFlowFenceSequence')
return sequenceOpen(code)
}

三个 enter 就是三层嵌套:整个公式 → 围栏 → 围栏上的 $ 序列。mdast-util 那边对 mathFlow 和 mathFlowFence 都写了 handler,mathFlowFenceSequence 却没人管——没有 handler 的 token 会被静默跳过,不报错也不留痕。所以 token 切得细一点没关系,用不上就当没看见。

接着 sequenceOpen 数 $ 的个数,不够两个就 nok——这就是「单个 $ 不算公式」的实现方式:

function sequenceOpen(code) {
if (code === codes.dollarSign) {
effects.consume(code)
sizeOpen++
return sequenceOpen
}
if (sizeOpen < 2) return nok(code) // 单个 $,不是公式,交还给上层
effects.exit('mathFlowFenceSequence')
return factorySpace(effects, metaBefore, types.whitespace)(code)
}

注意 return sequenceOpen——返回的是它自己。连续的 $ 会一直被这个函数吃掉,直到遇到别的字符才往下走。这是状态机最典型的写法:一个状态函数里既有自环,也有出口。

写状态机最麻烦的是「记不清现在处理到哪」。两个技巧:

  • 状态用闭包变量记(sizeOpen、alias、startMarkerCount),不要试图塞进 token
  • 在状态函数上方用 ^ 标出光标位置。micromark 自己的源码全是这么写的,照着抄能省一半调试时间:
/**
* In opening fence sequence.
*
* ```markdown
* > | $$
* ^
* | \frac{1}{2}
* | $$
* ```
*/

第 3 步:resolve —— 只在「配对不确定」时需要

块级语法通常不需要 resolve:$$ 出现时就知道要开始,闭合 $$ 出现时就知道要结束。

行内语法就不一样了。~~a~~ 里的两个 ~,tokenize 是分两次被调用的——第一次遇到 ~ 时不知道它是开还是闭,只能先切一个临时 token,打上「可能开」「可能闭」的标记,等整段行内内容解析完再回头配对。

gfm-strikethrough 就是这么做的:

// tokenize 阶段:先切出临时 token 并标记 _open / _close
function tokenizeStrikethrough(effects, ok, nok) { /* ... */ }
// 解析完后统一配对
function resolveAllStrikethrough(events, context) {
let index = -1
while (++index < events.length) {
if (
events[index][0] === 'enter' &&
events[index][1].type === 'strikethroughSequenceTemporary' &&
events[index][1]._close
) {
let open = index
while (open--) {
if (
events[open][0] === 'exit' &&
events[open][1].type === 'strikethroughSequenceTemporary' &&
events[open][1]._open &&
// 开闭长度必须一致,~~ 不能配 ~
events[index][1].end.offset - events[index][1].start.offset ===
events[open][1].end.offset - events[open][1].start.offset
) {
events[index][1].type = 'strikethroughSequence'
events[open][1].type = 'strikethroughSequence'
// ... 再 splice 一个 strikethrough token 把两边包起来
}
}
}
}
}

events 是扁平的 [kind, token][] 数组,kind 只有 'enter' 和 'exit' 两种。所以 resolve 干的活就是在这个数组上做括号匹配:找到能闭合的,往回找能打开的,改类型,再 splice 插入一对包裹 token。

顺带一提,gfmStrikethrough 返回的扩展里除了 text 还声明了两个:

return {
text: { [codes.tilde]: tokenizer },
insideSpan: { null: [tokenizer] },
attentionMarkers: { null: [codes.tilde] }
}

attentionMarkers 是告诉强调算法「~ 也算边界标记」。少了这一行,~~a~~ 出现在 **bold** 里时配对会错乱。

什么时候可以跳过 resolve

如果定界符自带方向性——比如 [[ 和 ]]——tokenize 读到第一个 [ 时就已经知道这是开头了,不需要配对。本站的 wikilink 插件就完全没写 resolve。

第 4 步:mdast-util —— 把 token 变成节点

token 只是「切分结果」,还不是 mdast 节点。转换交给 mdast-util-from-markdown,你的扩展只需要提供 enter / exit 两张表,键是 token 类型名:

export function mathFromMarkdown() {
return {
enter: {
mathFlow: enterMathFlow,
mathFlowFenceMeta: enterMathFlowMeta,
mathText: enterMathText,
},
exit: {
mathFlow: exitMathFlow,
mathFlowFence: exitMathFlowFence,
mathFlowFenceMeta: exitMathFlowMeta,
mathFlowValue: exitMathData,
mathText: exitMathText,
mathTextData: exitMathData,
},
}
}

四个上下文方法最常用:

方法作用
this.enter(node, token)建节点并压栈
this.exit(token)弹栈
this.sliceSerialize(token)取 token 对应的原始文本
this.buffer() / this.resume()开始攒 / 取出中间的文本(跨多个 token 时用)

enterMathFlow 里有一处值得停下来看:

function enterMathFlow(token) {
const code = {
type: 'element',
tagName: 'code',
properties: { className: ['language-math', 'math-display'] },
children: [],
}
this.enter(
{
type: 'math',
meta: null,
value: '',
data: { hName: 'pre', hChildren: [code] },
},
token,
)
}

data.hName 和 data.hProperties——上一篇「节点编辑」里讲过的字段,在这里出现了。这就是 mdast 和 hast 之间的暗号:remark-rehype 看到 hName 就不走默认映射,直接把节点渲染成 <pre><code class="language-math math-display">。

换句话说,你可以在 micromark 阶段就把最终 HTML 的形状定好,后面连 rehype 插件都省了。

然后 exitMathFlow 把攒到的文本写进 value:

function exitMathFlow(token) {
const data = this.resume().replace(/^(\r?\n|\r)|(\r?\n|\r)$/g, '')
const node = this.stack[this.stack.length - 1]
this.exit(token)
node.value = data
node.data.hChildren[0].children.push({ type: 'text', value: data })
}

this.resume() 取的是从 exitMathFlowFence 里 this.buffer() 开始攒的全部内容——中间那些 mathFlowValue token 的 exit 转给了默认的 data handler(exitMathData),由它自动追加。

第 5 步:补类型声明

token 类型名是字符串,TypeScript 默认不认识。在包的入口加一段声明合并:

declare module 'micromark-util-types' {
interface TokenTypeMap {
mathFlow: 'mathFlow'
mathFlowFence: 'mathFlowFence'
mathFlowFenceSequence: 'mathFlowFenceSequence'
mathFlowValue: 'mathFlowValue'
mathText: 'mathText'
}
}

interface 合并是自动的,只要这个 .d.ts 被加载过就生效。不写也能跑,但 effects.enter('mathFlow') 会一路飘红。

第 6 步:装配

上文 ### remark 已经讲过,这里只补一句:三个扩展包各自对应 data 里的一个数组,插件本身不需要知道它们怎么被消费。先写 micromark 扩展,再写 mdast-util 扩展,最后用一个十几行的 remark 插件把它们登记进去——这个顺序不要反过来。

拆开讲还是有点抽象。这个博客对 [[...]] / ![[...]] 的支持就是按上面六步写的,代码在 src/plugins/wiki/ 下:

src/plugins/wiki/
├── syntax.ts # construct + tokenize 状态机
├── fromMarkdown.ts # token → mdast 节点
└── remarkWikiLink.ts # 装配

syntax.ts:一个不需要 resolve 的状态机

[[...]] 的定界符自带方向,所以只写 tokenize:

function tokenize(effects, ok, nok) {
let data = false
let alias = false
let startMarkerCount = 0
let endMarkerCount = 0
return start
function start(code) {
if (code === startMarker) {
// [
effects.enter('wikiLink')
effects.enter('wikiLinkMarker')
return consumeStart(code)
}
if (code === embedStartMarker) {
// !
effects.enter('wikiLink', { isType: 'embed' })
effects.enter('wikiLinkMarker', { isType: 'embed' })
return consumeStart(code)
}
return nok(code)
}
function consumeStart(code) {
if (startMarkerCount === 2) {
// 已经吃掉 [[
effects.exit('wikiLinkMarker')
return consumeData(code)
}
if (code === startMarker || code === embedStartMarker) {
if (code === startMarker) startMarkerCount++
effects.consume(code)
return consumeStart
}
return nok(code)
}
function consumeTarget(code) {
if (code === aliasMarker) {
// |
if (!data) return nok(code)
effects.exit('wikiLinkTarget')
effects.enter('wikiLinkAliasMarker')
return consumeAliasMarker(code)
}
if (code === endMarker) {
// ]
if (!data) return nok(code)
effects.exit('wikiLinkTarget')
effects.exit('wikiLinkData')
effects.enter('wikiLinkMarker')
return consumeEnd(code)
}
if (isEndOfLineOrFile(code)) return nok(code)
data = true
effects.consume(code)
return consumeTarget
}
// consumeAlias / consumeAliasMarker / consumeEnd 略
}
const wikiLinkConstruct = { tokenize }
return {
text: {
[codes.leftSquareBracket]: wikiLinkConstruct,
[codes.exclamationMark]: wikiLinkConstruct,
},
}

几处值得注意的地方:

  • { isType: 'embed' } —— effects.enter 的第二个参数可以挂任意字段,这里用它区分 [[x]] 和 ![[x]]。这些字段会原样出现在 token 上,mdast-util 那边能读回来。
  • ! 也要注册 —— ![[...]] 的首字符是 !。不注册 text[codes.exclamationMark],embed 语法永远不会被触发。而 ! 原生是 labelStartImage,所以这里实际是「先试 wikilink,失败再退回图片」——nok 回滚的机制正好保证了这个顺序。
  • data / alias 两个布尔量 —— 用来拒绝 [[]] 和 [[a|]] 这种空内容。在状态机里做校验,比事后在树上检查干净得多。

fromMarkdown.ts:节点长什么样

function enterWikiLink(token) {
node = {
type: 'wikiLink',
data: {
isEmbed: token.isType === 'embed', // ← 就是上面挂上去的字段
target: null,
alias: null,
hName: null,
hProperties: null,
hChildren: null,
},
}
this.enter(node, token)
}
function exitWikiLinkTarget(token) {
this.stack[this.stack.length - 1].data.target = this.sliceSerialize(token)
}

最后 exitWikiLink 里根据 isEmbed 和文件后缀决定 hName:图片给 img,视频给 video,PDF 给 iframe,普通链接给 a。整棵树的 HTML 形态在 mdast 阶段就定完了,remark-rehype 只是照着念。

这个插件的遗留问题

它有个绕不过去的缺陷:hName: 'img' 生成的是「原生」<img>,走不到 Astro 的图片管线——没有 webp 转换,没有宽高推断,也没有懒加载。

所以本站后来补了一个 remarkWikiImage,排在 wikilink 之后,把图片嵌入节点换成标准的 image 节点:

parent.children[i] = {
type: 'image',
url: rel,
alt: node.data.alias || path.basename(target),
title: null,
position: node.position,
}

这也是 micromark 插件的通病:它在语法层面做得越彻底,就越容易绕过框架的约定。什么时候该自己写语法、什么时候该在后面补一个 visit 插件兜底,是这套生态里最需要经验判断的地方。

延伸阅读

按「从哪读起」的顺序排,每条后面说明该看什么:

Footnotes

  1. 这位作者是 rk-terence,在此表示对该作者的感谢。博客原文:编写 remark 插件实现自定义扩展语法 ↩

  2. 该部分信息来自 https://github.com/micromark/micromark#micromark ↩

#remark #markdown #unified #mdx #AI生成