Last updated on

如何编写 markdown-it 插件

作为一份 markdown-it 插件编写的参考,引导新手认识这一生态和里面可以做修改的属性

前言

markdown-it 的官方文档只是给了一部分案例,用于指导如何使用它的 API,将它接入到 Web 程序中,但是光看它的文档,我并没有找到写插件的头绪。因此,写插件的最好办法就是抄别人的代码。

当然,也是有了官方给的一个小小的案例,我才得以写出比较好的插件。网址在官网也是给出了,也得感谢一下这些为社区做出巨大贡献的维护者们。

Markdown-it 的规则

这部分其实还是看官方文档更好一些,毕竟他们写得很全,通过终端打印出来的毕竟很难看全。

Markdown-it 对于 markdown 的处理是基于 token stream 的,因此想要扩展的话,只需要扩展 token 处理函数即可。

如果读者查看 https://markdown-it.github.io/ 这个 demo 网站的 debug 栏,可以发现,除了 type: "inline" 的 token 之外,整个 token 流几乎都是扁平的,它不像 remarkjs 生成的 AST 那样树形嵌套。它能做到这样一点,就是因为有 nesting 这个属性:

  • 取值为 1 时代表了一个 html 标签的开始,如 <span>
  • 取值为 -1 时表示 html 标签的结束,如 </span>
  • 取值为 0 时代表自闭合的标签,如 <br />

这样做会让程序几乎没有递归,代码也变得非常简单。在翻译为最终代码的时候,程序读取这个扁平的 token 流,根据读取到的 token 的属性来生成 html 字符串。

一个 token 样例
{
"type": "paragraph_open",
"tag": "p",
"attrs": null,
"map": [5, 7],
"nesting": 1,
"level": 2,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": true
}

其对应的 HTML 就是 <p>,如果 attrs 中有值,则将会作为这个标签的属性。

上面这个样例中,type 字段表明当前 token 的类型,同时也解答了官方给的插件案例中,莫名其妙冒出来的 bullet_list_open 是从哪来的。也就是说,只需要在 demo 网站上编写 markdown,转到 debug 标签栏,就可以查看当前 token 的类型,即可按照官方案例那样进行修改了。

- a
转换的结果
[
{
"type": "bullet_list_open",
"tag": "ul",
"attrs": null,
"map": [
0,
1
],
"nesting": 1,
"level": 0,
"children": null,
"content": "",
"markup": "-",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "list_item_open",
"tag": "li",
"attrs": null,
"map": [
0,
1
],
"nesting": 1,
"level": 1,
"children": null,
"content": "",
"markup": "-",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "paragraph_open",
"tag": "p",
"attrs": null,
"map": [
0,
1
],
"nesting": 1,
"level": 2,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": true
},
{
"type": "inline",
"tag": "",
"attrs": null,
"map": [
0,
1
],
"nesting": 0,
"level": 3,
"children": [
{
"type": "text",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 0,
"children": null,
"content": "a",
"markup": "",
"info": "",
"meta": null,
"block": false,
"hidden": false
}
],
"content": "a",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "paragraph_close",
"tag": "p",
"attrs": null,
"map": null,
"nesting": -1,
"level": 2,
"children": null,
"content": "",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": true
},
{
"type": "list_item_close",
"tag": "li",
"attrs": null,
"map": null,
"nesting": -1,
"level": 1,
"children": null,
"content": "",
"markup": "-",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "bullet_list_close",
"tag": "ul",
"attrs": null,
"map": null,
"nesting": -1,
"level": 0,
"children": null,
"content": "",
"markup": "-",
"info": "",
"meta": null,
"block": true,
"hidden": false
}
]

把官方给的一个案例贴过来,可以看到其实就是给默认方法注入了一些语句,以达到扩展的功能。

const MarkdownIt = require('markdown-it')
const md = new MarkdownIt()
const proxy = (tokens, idx, options, env, self) => self.renderToken(tokens, idx, options)
const defaultRenderer = md.renderer.rules.bullet_list_open || proxy
md.renderer.rules.bullet_list_open = (tokens, idx, options, env, self) => {
// Make your changes here ...
tokens[idx].attrJoin('class', 'lorem_ipsum')
// ... then render it using the existing logic
return defaultRenderer(tokens, idx, options, env, self)
}
console.log(md.render('- Hello World'))

通过 log 能看到什么

Markdown-it 对象通过 new 来创建。

const md = new MarkdownIt()
console.dir(md, { depth: 0 })
Terminal window
MarkdownIt {
inline: [ParserInline],
block: [ParserBlock],
core: [Core],
renderer: [Renderer],
linkify: [LinkifyIt],
validateLink: [Function: validateLink],
normalizeLink: [Function: normalizeLink],
normalizeLinkText: [Function: normalizeLinkText],
utils: [Object],
helpers: [Object],
options: [Object]
}

首先可以看到的是 inlineblock 这两个 parser,这两个 parser 分别包含了下面这些规则

console.log('inline =', md.inline.ruler.__rules__.map(i => i.name))
inline = [
'text', 'linkify',
'newline', 'escape',
'backticks', 'strikethrough',
'emphasis', 'link',
'image', 'autolink',
'html_inline', 'entity'
]
console.log('block =', md.block.ruler.__rules__.map(i => i.name))
block = [
'table', 'code',
'fence', 'blockquote',
'hr', 'list',
'reference', 'html_block',
'heading', 'lheading',
'paragraph'
]

如果想要扩展这些规则,那么就可以使用 beforeafter 来注入自定义的方法了。

另外还有 renderer 属性,也包含了一些规则

console.log('renderer =', Object.keys(md.renderer.rules))
renderer = [
'code_inline', 'code_block',
'fence', 'image',
'hardbreak', 'softbreak',
'text', 'html_block',
'html_inline'
]

按照官方的样例来说,这里的规则应该不止这些,至少应该还包含 bullet_list_open 之类的规则。至于为什么没有出现,我就不知道了。

其他人的插件是怎么写的

markdown-it 插件的生态还是相当丰富的,在 https://npmjs.com 搜索可以看到相当多的插件,虽然有些确实有点年久失修了。不好好维护插件的开发者快来挨骂!

我们来看两种经典的插件编写案例。

基于 Code Block 的插件

这类插件还是非常丰富的,包括 markdown-it-prism, markdown-it-shiki, markdown-it-mermaid 等等。它们的共同特征就是扩展了 code block 的语法,通过识别当前代码块的语言,从而进行自定义的预处理。

代码高亮插件

Prism 和 Shiki 都是用于代码高亮的插件,原理是将包裹在代码块中的代码进行语法分析,然后更改每个片段的 CSS,最后将转换完成后的 HTML 字符串代替原来的 <pre> 块内的元素。具体的代码分析模块其实不是我们主要去研究的,我们要熟悉的是怎样将插件接入进去。

const MarkdownItShiki: MarkdownIt.PluginWithOptions<Options>
= (markdownit, options = {}) => {
// ... preprocessors
markdownit.options.highlight = (code, lang, attrs) => {
return highlightCode(
code,
lang || 'text',
)
}
}

上面的代码中,将高亮接入到 markdown-it 中的关键就在于修改 options.highlight 函数,使得这个函数返回已经高亮过的 HTML 字符串。其中,code 指代包在代码块中的代码字符串,lang 指代写在 ``` 后的语言选项,attrs 指代写在语言后面的其他参数。

自从 JS 工具库代码越来越卷之后,就有不少开发者尝试实现越来越多复杂的功能,也因此出现了 attrs 这个参数,用于代码块的一些扩展功能,主要就是行高亮、词高亮等功能吧。

功能性代码块

像是 mermaid, custom container 等功能,也是基于已有的代码块 API 进行扩展,识别相应的语言,并作相应的处理。而参考别人的插件,发现实际上也就是更改原有的渲染函数。

_public/md-plugins/fence-processor.excalidraw.svg

一旦涉及到更改原有的函数,我们又不可能考虑的非常全面,因此就需要保存原生的渲染函数作为 fallback.

const defaultRenderer = md.renderer.rules.fence!.bind(md.renderer.rules)

上面的 fence 就是指,被 ``` 包裹起来的代码块的渲染规则。我们将它先保存起来。

接下来就是更改 fence 函数本体了,采用的方式很暴力。

md.renderer.rules.fence = (tokens, index, options, env, slf) => {
const token = tokens[index]
if (token.info.trim() === 'mermaid') {
const code = token.content.trim()
return renderMermaid(code)
}
return defaultRenderer(tokens, index, options, env, slf)
}

从这段代码来看,就是在默认渲染器之前,插入一个预判断,如果符合这个条件,那么就使用自定义的渲染器,而不使用原生的渲染器。这样看来,其实基于代码块的插件是最好写的。

关于 token 的内容

如果不清楚 token 里面具体是什么,当然可以尝试将它 log 出来,查看它内部的参数。

基于行内特殊字符的插件

最经典的莫过于 markdown-it-katex 了,它能够让 markdown 支持数学公式的渲染,然而想要编写一个这样的插件还真的不容易。

前面基于代码块的插件只需要修改代码生成器,也就是只需要将 token 转换为 HTML 代码。但是这种基于行内的插件还需要一些前驱的步骤,比如下图中出现的修改 tokenizer.

_public/md-plugins/escape-processor.excalidraw.svg

如果查看 markdown-it-katex 的代码,可以发现有这样两个语句

md.inline.ruler.after('escape', 'math_inline', math_inline)
md.renderer.rules.math_inline = inlineRenderer
  • 第一行的作用就是扩展了行内元素的识别规则,在遇到一些界定符的时候,会进入 math_inline 函数,如果符合它的规则,那么就会生成一个 math_inline 类型的 token,用于后续的代码生成。
  • 第二行的作用就是为 renderer 添加一种新的规则,当识别到 math_inline 的 token 时,就能走 inlineRenderer 的逻辑,生成 KaTeX\KaTeX 的 HTML 字符串。

理清这一部分逻辑之后,代码编写就不是什么大问题了。

插件的接口部分

const MarkdownItKaTeX: MarkdownIt.PluginSimple = (md) => {
md.inline.ruler.after('escape', 'math_inline', math_inline)
md.renderer.rules.math_inline = inlineRenderer
}

Tokenizer 部分

function math_inline(state: StateInline, silent: boolean) {
// 如果不符合 $(.+?)$ 的要求,返回 false
if (!isValid(state))
return false
// 从 state 读取符合条件的 $(.+?)$, 起始和结束分别为 start 和 end
let start, end, token
// silent 时表示静默不处理
if (!silent) {
// 向 token 流中添加一个类型为 `math_inline` 的 token
token = state.push('math_inline', 'math', 0)
token.markup = '$'
// 将 token 的内容设定为识别到的 tex 字符串
token.content = state.src.slice(start, end)
}
// 将字符指针移出公式的范围
state.pos = end + 1
// 返回值为 true 应该表示当前的 token 生效
return true
}

代码生成部分

/** 生成 KaTeX 的 HTML 字符串 */
function inlineRenderer(code: string) {
try {
return katex.renderToString(tex, { throwOnError: false })
}
catch (err) {
return `<pre style="white-space: normal; background-color: #7f7f7f18; padding: 0.5rem;">${err}</pre>`
}
}

基于行内的插件有所了解了,那么基于块元素的也能够举一反三了。

一些细节

before 还是 after

我们发现插件在定制规则的时候,有些会使用 before,有些会使用 after,从常规的思维来看应该也很明了,before 就是会在 markdown 被处理成 token 和 html 之前就会被自定义的 tokenizer 处理,而 after 就是在被默认处理器编译过后,做的后续处理。

markdown-it-katex 中,我们注意到它使用的是 after,因为在原生的 markdown-it 中,$ 符号并不会做任何处理,也就是说这个字符串会被原样保留,因此作为后处理是没有任何问题的。

通常情况下,基于行内的处理可以用 after,但是基于块元素的,也就是处理 paragraph, blockquote 这种类型时,有时需要使用 before 来构造一个比原生处理器优先级更高的预处理器。因为 paragraph 这种元素通常是嵌套的,在处理 paragraph 之后,就必然会进一步处理段落内部的行内文本,破坏了段落这样一个应该被看作整体的元素。而解决方式可能就是添加一些 alt,让其他的块也被这个处理器处理。

行内元素 state 的属性

部分类型声明

declare class StateInline {
constructor(src: string, md: MarkdownIt, env: any, outTokens: Token[])
/**
* 完整的 markdown 字符串
*/
src: string
/**
* token 序列
*/
tokens: Token[]
tokens_meta: Array<StateInline.TokenMata | null>
/**
* markdown 字符串指针
*/
pos: number
/**
* 行尾的索引,如果 pos >= posMax 通常就不用再处理该行内元素了
*/
posMax: number
level: number
pending: string
md: markdownit
/**
* 将新的 token 加入 token stream
* If pending text exists - flush it as text token
*/
push(type: string, tag: string, nesting: Token.Nesting): Token
}

但是呢,我并不知道这个 pending 有什么用,之后等有人再来完善一下吧。

完整的类型声明 @types/markdown-it/lib/rules_inline/state_inline.d.ts

块元素的 state 属性

declare class StateBlock {
constructor(src: string, md: MarkdownIt, env: any, tokens: Token[])
/**
* 完整的 markdown 字符串
*/
src: string
tokens: Token[]
/**
* 行首的索引,即每行第一个字符在 src 中的 index
*/
bMarks: number[]
/**
* 行尾的索引,即每行最后一个字符在 src 中的 index
*/
eMarks: number[]
/**
* 每一行中第一个不是空白字符串的索引
*/
tShift: number[]
/**
* 每行的缩进
*/
sCount: number[]
/**
* 当前指针所在的行号
*/
line: number
/**
* 总行数
*/
lineMax: number
md: markdownit
/**
* 将新的 token 加入 token stream
*/
push(type: string, tag: string, nesting: Token.Nesting): Token
}

在不少插件的案例中,我们会有下面的定义,分别指代的是 lineNum 行的第一个非空白字符索引和和行尾索引。

const start = state.bMarks[lineNum] + state.tShift[lineNum]
const max = state.eMarks[lineNum]

在块元素中,我们用的索引就是 line 这个关键字了,处理完块元素之后,就需要让 state.line 移动到下一个块元素了。

完整的类型声明 @types/markdown-it/lib/rules_block/state_block.d.ts

state 中的 md 引用

这个 md 引用应该就是当前 markdown-it 对象的一个引用,也就是说,用这个对象可以实现自定义处理函数的嵌套。至于嵌套成了什么样子,可以查看我曾经编写过的 mdit-plugin-callouts 插件,之所以能够在自定义块内再构造一个自定义块,实际上就是用了这样一种递归操作。

但是呢,我写这个插件的时间都好久远了,我都不知道它具体是怎么操作的了,就当作是这样干的吧。

作业

有了这些基础,我相信读者应该也有一定的能力依葫芦画瓢写一个插件了,那么就布置个作业吧。

作业 1

编写一个 wikilink 的 markdown-it 插件,能够将 ![[./abc.png]] 这种链接翻译为 <img src='./abc.png' />,同时也能够适配视频和音频,分别生成 <video><audio> 标签的元素。

作业答案可以参考 https://github.com/widcardw/mdit-plg-double-bracket-media

作业 2

编写一个 callouts 的 markdown-it 插件,能够将 Obsidian 的 Callouts 语法,翻译为带标题和内容的一个自定义块。要求标题和内容能够满足:标题中可以渲染诸如行内代码、数学公式(依靠已有的 markdown-it-katex 插件)等,内容中支持 markdwon 元素渲染、Callouts 的嵌套等功能。

作业答案可以参考 mdit-plugin-callouts,支持嵌套的难度可能稍大,可以留意 state.md.block.tokenize 方法。

#markdown-it #plugins