前言
最近看vuePress源码时发现在使用markdownLoader之余使用了大量的 markdown-it
插件,除了社区插件(如高亮代码、锚点、emoji识别等),同时也自行编写了很多自定义插件(如内外链区分渲染等)。
文章结合源码和自己之前写过的插件来详细解读如何编写一个 markdown-it
插件规则。
简介
markdown-it
是一个辅助解析markdown的库,可以完成从 # test
到 <h1>test</h1>
的转换,渲染过程和babel类似为Parse -> Transform -> Generate。
Parse
source通过3个嵌套的规则链core、block、inline进行解析:
core
core.rule1 (normalize)
...
core.ruleX
block
block.rule1 (blockquote)
...
block.ruleX
inline (applied to each block token with "inline" type)
inline.rule1 (text)
...
inline.ruleX
解析的结果是一个token列表,将传递给renderer以生成html内容。
如果要实现新的markdown语法,可以从Parse过程入手:
可以在 md.core.ruler
、md.block.ruler
、md.inline.ruler
中自定义规则,规则的定义方法有 before
、after
、at
、disable
、enable
等。
// @vuepress/markdown代码片段
md.block.ruler.before('fence', 'snippet', function replace(state, startLine, endLine, silent) {
//...
});
上述代码在 md.block.ruler.fence
之前加入snippet规则,用作解析 <<< @/filepath
这样的代码,它会把其中的文件路径拿出来和 root 路径拼起来,然后读取其中文件内容。
具体代码就不详细分析了,一般parse阶段用到的情况比较少,感兴趣的可以自行查看vuePress源码。
Transform
Token
通过官方在线示例拿 # test
举例,会得到如下结果:
[
{
"type": "heading_open",
"tag": "h1",
"attrs": null,
"map": [
0,
1
],
"nesting": 1,
"level": 0,
"children": null,
"content": "",
"markup": "#",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "inline",
"tag": "",
"attrs": null,
"map": [
0,
1
],
"nesting": 0,
"level": 1,
"children": [
{
"type": "text",
"tag": "",
"attrs": null,
"map": null,
"nesting": 0,
"level": 0,
"children": null,
"content": "test",
"markup": "",
"info": "",
"meta": null,
"block": false,
"hidden": false
}
],
"content": "test",
"markup": "",
"info": "",
"meta": null,
"block": true,
"hidden": false
},
{
"type": "heading_close",
"tag": "h1",
"attrs": null,
"map": null,
"nesting": -1,
"level": 0,
"children": null,
"content": "",
"markup": "#",
"info": "",
"meta": null,
"block": true,
"hidden": false
}
]
使用更底层的数据表示Token,代替传统的AST。区别很简单:
- 是一个简单的数组
- 开始和结束标签是分开的
- 会有一些特殊token (type: "inline") 嵌套token,根据标记顺序(bold, italic, text, ...)排序
更详细的数据模型可以通过 Token类定义 查看。
Renderer
token生成后被传递给renderer,renderer会将所有token传递给每个与token类型相同的rule规则。
renderer的rule规则都定义在 md.renderer.rules[name]
,是参数相同的函数。
Rules
代表对token的渲染规则,可以被更新或扩展,后续的实例基本都会从这里展开。
用法
基础用法
const MarkdownIt = require('markdown-it');
const md = new MarkdownIt();
const result = md.render('# test');
预设和选项
预设(preset)定义了激活的规则以及选项的组合。可以是 commonmark
、zero
、default
。
- commonmark 严格的 CommonMark 模式
- default 默认的 GFM 模式, 没有 html、 typographer、autolinker 选项
- zero 无任何规则
// commonmark 模式
const md = require('markdown-it')('commonmark');
// default 模式
const md = require('markdown-it')();
// 启用所有
const md = require('markdown-it')({
html: true,
linkify: true,
typographer: true
});
选项文档:
参数 | 类型 | 默认值 | 说明 |
---|---|---|---|
html | Boolean | false | 在源码中启用 HTML 标签 |
xhtmlOut | Boolean | false | 使用 / 来闭合单标签 (比如 <br /> )这个选项只对完全的 CommonMark 模式兼容 |
breaks | Boolean | false | 转换段落里的 \n 到 <br /> |
langPrefix | String | language- | 给围栏代码块的 CSS 语言前缀 对于额外的高亮代码非常有用 |
linkify | Boolean | false | 将类似 URL 的文本自动转换为链接 |
typographer | Boolean | false | 启用语言无关的替换 美化引号 |
quotes | String \ Array | “”‘’ | 双引号或单引号或智能引号替换对,当 typographer 启用时 |
highlight | Function | function (str, lang) { return ''; } | 高亮函数,会返回转义的HTML或'' 如果源字符串未更改,则应在外部进行转义 如果结果以 <pre ... 开头,内部包装器则会跳过 |
实例
transform阶段一般有两种写法
- 重写 md.renderer.rules[name]
- require('markdown-it')().use(plugin1).use(plugin2, opts, ...)
在搭建组件库文档过程中,需要判断是否为http开头的外部链接,内链直接通过a
标签跳转相对路由,外链则新开窗口打开。
代码地址
const MarkdownIt = require('markdown-it');
const md = new MarkdownIt({
html: true,
highlight,
...options
});
const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
const hrefAttr = tokens[idx].attrGet('href');
if (/^https?/.test(hrefAttr)) {
tokens[idx].attrPush(['target', '_blank']); // add new attribute
}
return defaultRender(tokens, idx, options, env, self);
};
plugin有 markdown-it-for-inline、markdown-it-anchor 等,以上例为例,如果你需要添加属性,可以在没有覆盖规则的情况下做一些事情。
接下来用markdown-it-for-inline插件来完成上例一样的功能。
const MarkdownIt = require('markdown-it');
const iterator = require('markdown-it-for-inline');
const md = new MarkdownIt({
html: true,
highlight,
...options
});
md.use(iterator, 'url_new_win', 'link_open', function (tokens, idx) {
const hrefAttr = tokens[idx].attrGet('href');
if (/^https?/.test(hrefAttr)) {
tokens[idx].attrPush(['target', '_blank']); // add new attribute
}
});
这比直接渲染器覆盖规则要慢,但写法更简单。
vuePress实例
如果上面我自己写的例子还比较难懂的话,接下去就拿vue的官方实例来讲解。
重写 md.renderer.rules.fence
规则,通过换行符 \n
的数量来推算代码行数,并生成带有行号的代码串,最后在外层包裹上一层绝对定位的样式。
代码地址
const fence = md.renderer.rules.fence
md.renderer.rules.fence = (...args) => {
const rawCode = fence(...args)
const code = rawCode.slice(
rawCode.indexOf('<code>'),
rawCode.indexOf('</code>')
)
const lines = code.split('\n')
const lineNumbersCode = [...Array(lines.length - 1)]
.map((line, index) => `<span class="line-number">${index + 1}</span><br>`).join('')
const lineNumbersWrapperCode =
`<div class="line-numbers-wrapper">${lineNumbersCode}</div>`
const finalCode = rawCode
.replace('<!--beforeend-->', `${lineNumbersWrapperCode}<!--beforeend-->`)
.replace('extra-class', 'line-numbers-mode')
return finalCode
}
需要注意的是 <!--beforeend-->
注释也是另一个内部插件 preWrapper 生成的,得到最终效果。
fence
这个规则用到的频率比较高,可以直接处理具体的代码块,例如 ElementUI
组件库中也有一段代码,利用了 vue
组件插槽的特性,将同一段 markdown
代码片段分别解析为代码插槽和 html
代码展示,非常精妙!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。