我在写博客的时候的一个需求是使用 Obsidian 格式的 callout。然而,我用来解析 markdown 转为 HTML 的 markdown-it 并没有原生的 callout 支持。同时,我在 npm 上也并没有搜到相关的插件。这个功能我又很想要,没办法,只能自己动手写插件了1

markdown-it 的架构

为了写 markdown-it 的插件,就必须了解其架构。其实 markdown-it 的架构在 Github 仓库上已经写了(markdown-it design principles)。不过,作为一个初学者,我也确实花了几天的时间才大概搞明白了架构,知道了如何来写扩展语法的插件。因此,我认为在这里写一下我的理解是有必要的——一方面方便自己理解,一方面可以帮助像我一样的初学者。

markdown-it 内部把 markdown 转化为 HTML 主要经过两个大的步骤:

  • Parsing。这个环节中,parser 将 markdown 所有的内容转化为中间体 Token 类型,然后返回一个 list2,这个 list 内部就是所有的 token。
  • Rendering。Rendering 是基于 token 进行的。在这个步骤里面,将会逐一遍历上一个步骤中返回的各个 token,把每个 token 都将转化为对应的 HTML string。这些得到的 HTML string 加起来,就构成了最终输出的 HTML string。

Token

我认为 markdown-it 的核心是 token,在进一步介绍 parsing 和 rendering 之前,我们先了解一下 token。

Markdown 文件中,大致可以分为

  • 以行为最小单位的内容,可以称作_块(block 元素)_;

    例子:段落、列表、代码块等多行文字构成的一大片内容。

  • 以字符为最小单位的内容,可以称作_行内元素(inline)_。Block 元素内可以有多种 inline 元素,

    例子:一个段落 block 内部可以有行内代码、加粗、倾斜、标题等 inline 元素。

markdown-it 中的 token 就是根据 block 和 inline 来定义 token 的类型的。对于每种 block 元素,都会生成对应种类的 token;对于每种 inline 元素,也会生成对应的 token。

Parsing

markdown-it 里面有预先定义好的各种 parsing 规则,每个规则都是一个生成 token 的函数。主要分为两大类规则。在对 markdown 进行解析时,

  1. 粗粒度处理(block)。首先进入 markdown 文件的第一行,依次尝试应用 block 级的所有规则(block 级的规则由 md.block.ruler 管理)。如果某个 block 级规则作用成功,说明这个发现了这个规则对应的 block 元素,那么将会生成对应的 token(s),然后从当前 block 元素的下一行继续同样的搜索,直到遍历完所有行。

这个步骤之后,markdown 内部的所有 block 元素将会被找出,生成对应的 block 级 token。如果当前 block 元素的内容需要进一步进行行内元素处理,那么还要生成对应的 inline 类型的 token。因此需要注意的是,markdown-it 中 block 元素不一定只生成一个 token。比如对于一个段落 block,将会生成三种 token:paragraph_openinline 和 paragraph_close

最终所有的 token 保存在一个 token list 中。

例子:对于一个段落

Hello *world*!

经过处理之后将会生成的 token 为

paragraph_open
inline
paragraph_close
  1. 细粒度处理(inline)。依次遍历前一步生成的 token list。查看生成的 block 级的 token 是否有 inline 类型 token。如果有,对之应用 inline 级的规则们(inline 级的规则由 md.inline.ruler 管理),然后生成 inline 元素对应的 token,作为 inline 类型 token 的子元素。

例子:接着上一步骤的例子,经过处理之后将会变成

paragraph_open
inline
	text
	em_open
	text
	em_close
	text
paragraph_close

Rendering

相比较于 parsing,rendering 比较简单。parsing 步骤中得到的每个 token 内部都存储有自己的 type,markdown-it 也有各种预先定义好的 render 规则(存储在 md.renderer.rules 里面,每个规则都是一个函数,简单来说,这个函数的输入是 token,输出是这个 token 转化成为的 HTML string)。在遍历各个 token 的过程中,对每个 token,都会查看一下是否有和 token 的 type 同名的规则。如果有,则令对应的规则作用于这个 token,得到该 token 的 HTML string;如果没有,则使用 markdown-it 的默认规则来作用于这个 token。

接着 Parsing 中的例子:

paragraph_open         =>  <p>
inline                   
    text               =>  Hello  
    em_open            =>  <em>
    text               =>  world
    em_close           =>  </em>
    text               =>  !
paragraph_close        =>  </p>

因此,最终生成的 HTML string 为

<p>Hello <em>world</em>!</p>

如何写插件?

知道了 markdown-it 的架构,撰写插件就不难了。假设我需要自定义一个新的 markdown 语法,那么插件需要做的事情无非两点:

  1. 新增 parsing 规则,识别新语法规定的范式(pattern),并将对应的内容转化为 token。

    首先我们需要确认我们所新增的 parsing 规则是 block 级的还是 inline 级的。对于笔者想要的 callout 而言,callout 内部可以支持段落、各种行内元素等,这明显是一个 block 级的规则。而如果想写一个行内公式(如$a^2$)的规则,这明显是一个 inline 级的规则。确定好我们新的规则属于哪一等级后,下一步需要编写对应的规则,然后将对应的规则加入到 markdown-it 中去。

    往 markdown-it 中加入规则的方式:

    • 对于block规则,修改 md.block.ruler。具体的操作可以是 pushafterbeforeat 等(具体可以参考Ruler API)。比如我想添加在原 blockquote 规则前面,可以使用

      // "callout"是新增规则的名字,parseCallout是规则对应的函数。alt是这个规则可以打断的规则们,一般我们不需要处理。这里我直接把blockquote的alt拿来用了
      md.block.ruler.before("blockquote", "callout", parseCallout, { alt: [ 'paragraph', 'reference', 'blockquote', 'list' ] })
      
    • 对于 inline 规则,修改 md.inline.ruler3

  2. 新增对应的 rendering 规则,让生成的新的 token 能够有效地得到 render。

    这里可以分情况考虑。

    • 如果你生成的新的 token 基于默认的 renderer 规则即可生成想要的效果,那么就不需要再专门写一个 renderer 规则;

    • 否则,还是需要写一个 renderer 规则函数。函数写好后,假定你的函数是 render,规则名是 rule_name,那么:

      md.renderer.rules[rule_name] = render;
      

知道了要干的事情之后,我们就可以着手写一个插件了。markdown-it 的插件就是一个函数,输入是 md, options。应用插件时,需要 md.use(plugin, options),这一句代码内部做的操作相当于是调用 plugin(md, options)。因此写好一个函数 plugin,在这个函数内部做好上面的两件事,即可。

markdown-it-regexp

我也是后来写 Wikilink 的需求的时候发现了这个插件。这个 npm 库可以简化处理 inline 元素的插件的编写4。如果已经理解了前面的内容,它的源码很好懂。

Footnotes

  1. 事实上,我在早期的网站搭建中使用 markdown-it 来处理 markdown 文件的渲染。新版网站使用 Astro,因为 Astro 支持 unified 生态,所以就有了 这篇文章,里面讲了如何在 unified 生态下编写 markdown 的插件。 

  2. 由于笔者写 Python 比较多,所以会把一维数组叫做 list。JavaScript 里对应的结构为Array。 

  3. md.inline.rulermd.block.ruler都是同一个Ruler的不同实例。因此可以使用同样的方法来修改规则。 

  4. 副作用是效率低一些。见 这个包对应github仓库的readme