我在写博客的时候的一个需求是使用 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 进行解析时,
- 粗粒度处理(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_open
,inline
和 paragraph_close
。
最终所有的 token 保存在一个 token list 中。
例子:对于一个段落
Hello *world*!
经过处理之后将会生成的 token 为
paragraph_open
inline
paragraph_close
- 细粒度处理(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 语法,那么插件需要做的事情无非两点:
-
新增 parsing 规则,识别新语法规定的范式(pattern),并将对应的内容转化为 token。
首先我们需要确认我们所新增的 parsing 规则是 block 级的还是 inline 级的。对于笔者想要的 callout 而言,callout 内部可以支持段落、各种行内元素等,这明显是一个 block 级的规则。而如果想写一个行内公式(如
$a^2$
)的规则,这明显是一个 inline 级的规则。确定好我们新的规则属于哪一等级后,下一步需要编写对应的规则,然后将对应的规则加入到 markdown-it 中去。往 markdown-it 中加入规则的方式:
-
对于block规则,修改
md.block.ruler
。具体的操作可以是push
,after
,before
,at
等(具体可以参考Ruler API)。比如我想添加在原blockquote
规则前面,可以使用// "callout"是新增规则的名字,parseCallout是规则对应的函数。alt是这个规则可以打断的规则们,一般我们不需要处理。这里我直接把blockquote的alt拿来用了 md.block.ruler.before("blockquote", "callout", parseCallout, { alt: [ 'paragraph', 'reference', 'blockquote', 'list' ] })
-
对于 inline 规则,修改
md.inline.ruler
3。
-
-
新增对应的 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。如果已经理解了前面的内容,它的源码很好懂。