1

前言

最近看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.rulermd.block.rulermd.inline.ruler 中自定义规则,规则的定义方法有 beforeafteratdisableenable 等。

// @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)定义了激活的规则以及选项的组合。可以是 commonmarkzerodefault

  • 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
});

选项文档

参数类型默认值说明
htmlBooleanfalse在源码中启用 HTML 标签
xhtmlOutBooleanfalse使用 / 来闭合单标签 (比如 <br />
这个选项只对完全的 CommonMark 模式兼容
breaksBooleanfalse转换段落里的 \n<br />
langPrefixStringlanguage-给围栏代码块的 CSS 语言前缀
对于额外的高亮代码非常有用
linkifyBooleanfalse将类似 URL 的文本自动转换为链接
typographerBooleanfalse启用语言无关的替换
美化引号
quotesString \ Array“”‘’双引号或单引号或智能引号替换对,当 typographer 启用时
highlightFunctionfunction (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-inlinemarkdown-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 生成的,得到最终效果。
image.png

fence 这个规则用到的频率比较高,可以直接处理具体的代码块,例如 ElementUI 组件库中也有一段代码,利用了 vue 组件插槽的特性,将同一段 markdown 代码片段分别解析为代码插槽和 html 代码展示,非常精妙!

参考文档

markdown-it design principles
markdown-it


小皇帝James
600 声望7 粉丝

IT吴彦祖


« 上一篇
git分支规范