前言
在此系列文章的第一篇,我們介紹了 Vuepress 如何讓 Markdown 支持 Vue 組件的,但沒有提到非 Vue 組件的其他部分如何被解析。
今天,我們就來(lái)看看 Vuepress 是如何利用 markdown-it 來(lái)解析 markdown 代碼的。
markdown-it 簡(jiǎn)介
markdown-it 是一個(gè)輔助解析 markdown 的庫(kù),可以完成從 # test 到 <h1>test</h1> 的轉(zhuǎn)換。
它同時(shí)支持瀏覽器環(huán)境和 Node 環(huán)境,本質(zhì)上和 babel 類似,不同之處在于,babel 解析的是 JavaScript。
說(shuō)到解析,實(shí)際上稱為解釋(interpreter)或者編譯(compiler)更為令人熟悉。總歸繞不開詞法分析和語(yǔ)法分析這兩個(gè)過程。
markdown-it 官方給了一個(gè)在線示例,可以讓我們直觀地得到 markdown 經(jīng)過解析后的結(jié)果。比如還是拿 # test 舉例,會(huì)得到如下結(jié)果:
[
{
"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
}
]
詞法分析,說(shuō)白了,就是把一段代碼拆分成若干個(gè)基本單元(token),這些基本單元又可以進(jìn)一步分類。這個(gè)過程稱之為 tokenizes。
語(yǔ)法分析,其實(shí)就是將最終要生成的代碼用一顆樹(ast)來(lái)表示,其中每個(gè)節(jié)點(diǎn)都是我們通過詞法分析得到的 token 對(duì)象。顯而易見,我們得到了一顆這樣的 AST:

我們也可以手動(dòng)執(zhí)行下面代碼得到同樣的結(jié)果:
const md = new MarkdownIt()
let tokens = md.parse('# test')
console.log(tokens)
主要 API 介紹
模式
markdown-it 提供了三種模式:commonmark、default、zero。分別對(duì)應(yīng)最嚴(yán)格、GFM、最寬松的解析模式。
解析
markdown-it 的解析規(guī)則大體上分為塊(block)和內(nèi)聯(lián)(inline)兩種。具體可體現(xiàn)為 MarkdownIt.block 對(duì)應(yīng)的是解析塊規(guī)則的 ParserBlock, MarkdownIt.inline 對(duì)應(yīng)的是解析內(nèi)聯(lián)規(guī)則的 ParserInline,MarkdownIt.renderer.render 和 MarkdownIt.renderer.renderInline 分別對(duì)應(yīng)按照塊規(guī)則和內(nèi)聯(lián)規(guī)則生成 HTML 代碼。
規(guī)則
在 MarkdownIt.renderer 中有一個(gè)特殊的屬性:rules,它代表著對(duì)于 token 們的渲染規(guī)則,可以被使用者更新或擴(kuò)展:
var md = require('markdown-it')();
md.renderer.rules.strong_open = function () { return '<b>'; };
md.renderer.rules.strong_close = function () { return '</b>'; };
var result = md.renderInline(...);
比如這段代碼就更新了渲染 strong_open 和 strong_close 這兩種 token 的規(guī)則。
插件系統(tǒng)
markdown-it 官方說(shuō)過:
We do a markdown parser. It should keep the "markdown spirit". Other things should be kept separate, in plugins, for example. We have no clear criteria, sorry.
Probably, you will find CommonMark forum a useful read to understand us better.
一言以蔽之,就是 markdown-it 只做純粹的 markdown 解析,想要更多的功能你得自己寫插件。
所以,他們提供了一個(gè) API:MarkdownIt.use
它可以將指定的插件加載到當(dāng)前的解析器實(shí)例中:
var iterator = require('markdown-it-for-inline');
var md = require('markdown-it')()
.use(iterator, 'foo_replace', 'text', function (tokens, idx) {
tokens[idx].content = tokens[idx].content.replace(/foo/g, 'bar');
});
這段示例代碼就將 markdown 代碼中的 foo 全部替換成了 bar。
更多信息
可以訪問我國(guó)慶期間翻譯的中文文檔,或者官方 API 文檔。
vuepress 中的應(yīng)用
vuepress 借助了 markdown-it 的諸多社區(qū)插件,如高亮代碼、代碼塊包裹、emoji 等,同時(shí)也自行編寫了很多 markdown-it 插件,如識(shí)別 vue 組件、內(nèi)外鏈區(qū)分渲染等。
本文寫自 2018 年國(guó)慶期間,對(duì)應(yīng) vuepress 代碼版本為 v1.0.0-alpha.4。
入口
源碼
主要做了下面五件事:
- 使用社區(qū)插件,如 emoji 識(shí)別、錨點(diǎn)、toc。
- 使用自定義插件,稍后詳細(xì)說(shuō)明。
- 使用 markdown-it-chain 支持鏈?zhǔn)秸{(diào)用 markdown-it,類似我在第二篇文章提到的 webpack-chain。
- 參數(shù)可以傳 beforeInstantiate 和 afterInstantiate 這兩個(gè)鉤子,這樣方便暴露 markdown-it 實(shí)例給外部。
- dataReturnable 自定義 render:
module.exports.dataReturnable = function dataReturnable (md) {
// override render to allow custom plugins return data
const render = md.render
md.render = (...args) => {
md.__data = {}
const html = render.call(md, ...args)
return {
html,
data: md.__data
}
}
}
相當(dāng)于讓 __data 作為一個(gè)全局變量了,存儲(chǔ)各個(gè)插件要用到的數(shù)據(jù)。
識(shí)別 vue 組件
就做了一件事:替換默認(rèn)的 htmlBlock 規(guī)則,這樣就可以在根級(jí)別使用自定義的 vue 組件了。
module.exports = md => {
md.block.ruler.at('html_block', htmlBlock)
}
這個(gè) htmlBlock 函數(shù)和原生的 markdown-it 的 html_block 關(guān)鍵區(qū)別在哪呢?
答案是在 HTML_SEQUENCES 這個(gè)正則數(shù)組里添加了兩個(gè)元素:
// PascalCase Components
[/^<[A-Z]/, />/, true],
// custom elements with hyphens
[/^<\w+\-/, />/, true],
很明顯,這就是用來(lái)匹配帕斯卡寫法(如 <Button/>)和連字符(如 <button-1/>)寫法的組件的。
內(nèi)容塊
這個(gè)組件實(shí)際上是借助了社區(qū)的 markdown-it-container 插件,在此基礎(chǔ)上定義了 tip、warning、danger、v-pre 這四種內(nèi)容塊的 render 函數(shù):
render (tokens, idx) {
const token = tokens[idx]
const info = token.info.trim().slice(klass.length).trim()
if (token.nesting === 1) {
return `<div class="${klass} custom-block"><p class="custom-block-title">${info || defaultTitle}</p>\n`
} else {
return `</div>\n`
}
}
這里需要說(shuō)明一下的是 token 的兩個(gè)屬性。
info
三個(gè)反引號(hào)后面跟的那個(gè)字符串。nesting 屬性:
-
1意味著標(biāo)簽打開。 -
0意味著標(biāo)簽是自動(dòng)關(guān)閉的。 -
-1意味著標(biāo)簽正在關(guān)閉。
高亮代碼
- 借助了 prismjs 這個(gè)庫(kù)
- 將 vue 和 html 看做是同一種語(yǔ)言:
if (lang === 'vue' || lang === 'html') {
lang = 'markup'
}
- 對(duì)語(yǔ)言縮寫做了兼容,如 md、ts、py
- 使用 wrap 函數(shù)對(duì)生成的高亮代碼再做一層包裝:
function wrap (code, lang) {
if (lang === 'text') {
code = escapeHtml(code)
}
return `<pre v-pre class="language-${lang}"><code>${code}</code></pre>`
}
高亮代碼行
- 在別人的代碼基礎(chǔ)上修改的。
- 重寫了 md.renderer.rules.fence 方法,關(guān)鍵是借助一個(gè)正則判斷獲取要高亮的代碼行們:
const RE = /{([\d,-]+)}/
const lineNumbers = RE.exec(rawInfo)[1]
.split(',')
.map(v => v.split('-').map(v => parseInt(v, 10)))
然后條件渲染:
if (inRange) {
return `<div class="highlighted"> </div>`
}
return '<br>'
最后返回高亮行代碼 + 普通代碼。
腳本提升
重寫 md.renderer.rules.html_block 規(guī)則:
const RE = /^<(script|style)(?=(\s|>|$))/i
md.renderer.rules.html_block = (tokens, idx) => {
const content = tokens[idx].content
const hoistedTags = md.__data.hoistedTags || (md.__data.hoistedTags = [])
if (RE.test(content.trim())) {
hoistedTags.push(content)
return ''
} else {
return content
}
}
將 style 和 script 標(biāo)簽保存在 __data 這個(gè)偽全局變量里。這部分?jǐn)?shù)據(jù)會(huì)在 markdownLoader 中用到。
行號(hào)
重寫 md.renderer.rules.fence 規(guī)則,通過換行符的數(shù)量來(lái)推算代碼行數(shù),并再包裹一層:
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
內(nèi)外鏈區(qū)分
一個(gè) a 鏈接,可能是跳往站內(nèi)的,也有可能是跳往站外的。vuepress 將這兩種鏈接做了一個(gè)區(qū)分,最終外鏈會(huì)比內(nèi)鏈多渲染出一個(gè)圖標(biāo):

要實(shí)現(xiàn)這點(diǎn),vuepress 重寫了 md.renderer.rules.link_open 和 md.renderer.rules.link_close 這兩個(gè)規(guī)則。
先看 md.renderer.rules.link_open :
if (isExternal) {
Object.entries(externalAttrs).forEach(([key, val]) => {
token.attrSet(key, val)
})
if (/_blank/i.test(externalAttrs['target'])) {
hasOpenExternalLink = true
}
} else if (isSourceLink) {
hasOpenRouterLink = true
tokens[idx] = toRouterLink(token, link)
}
isExternal 便是外鏈的標(biāo)志位,這時(shí)如果它為真,則直接設(shè)置 token 的屬性即可,如果 isSourceLink 為真,則代表傳入了個(gè)內(nèi)鏈,整個(gè) token 將會(huì)被替換成 toRouterLink(token, link) :
function toRouterLink (token, link) {
link[0] = 'to'
let to = link[1]
// convert link to filename and export it for existence check
const links = md.__data.links || (md.__data.links = [])
links.push(to)
const indexMatch = to.match(indexRE)
if (indexMatch) {
const [, path, , hash] = indexMatch
to = path + hash
} else {
to = to
.replace(/\.md$/, '.html')
.replace(/\.md(#.*)$/, '.html$1')
}
// relative path usage.
if (!to.startsWith('/')) {
to = ensureBeginningDotSlash(to)
}
// markdown-it encodes the uri
link[1] = decodeURI(to)
// export the router links for testing
const routerLinks = md.__data.routerLinks || (md.__data.routerLinks = [])
routerLinks.push(to)
return Object.assign({}, token, {
tag: 'router-link'
})
}
先是 href 被替換成 to,然后 to 又被替換成 .html 結(jié)尾的有效鏈接。
再來(lái)看 md.renderer.rules.link_close :
if (hasOpenRouterLink) {
token.tag = 'router-link'
hasOpenRouterLink = false
}
if (hasOpenExternalLink) {
hasOpenExternalLink = false
// add OutBoundLink to the beforeend of this link if it opens in _blank.
return '<OutboundLink/>' + self.renderToken(tokens, idx, options)
}
return self.renderToken(tokens, idx, options)
很明顯,內(nèi)鏈渲染 router-link 標(biāo)簽,外鏈渲染 OutboundLink 標(biāo)簽,也就是加了那個(gè)小圖標(biāo)的鏈接組件。
代碼塊包裹
這個(gè)插件重寫了 md.renderer.rules.fence 方法,用來(lái)對(duì) <pre> 標(biāo)簽再做一次包裹:
md.renderer.rules.fence = (...args) => {
const [tokens, idx] = args
const token = tokens[idx]
const rawCode = fence(...args)
return `<!--beforebegin--><div class="language-${token.info.trim()} extra-class">` +
`<!--afterbegin-->${rawCode}<!--beforeend--></div><!--afterend-->`
}
將圍欄代碼拆成四個(gè)部分:beforebegin、afterbegin、beforeend、afterend。相當(dāng)于給用戶再自定義 markdown-it 插件提供了鉤子。
錨點(diǎn)非 ascii 字符處理
這段代碼最初是為了解決錨點(diǎn)中帶中文或特殊字符無(wú)法正確跳轉(zhuǎn)的問題。
處理的非 acsii 字符依次是:變音符號(hào) -> C0控制符 -> 特殊字符 -> 連續(xù)出現(xiàn)2次以上的短杠(-) -> 用作開頭或結(jié)尾的短桿。
最后將開頭的數(shù)字加上下劃線,全部轉(zhuǎn)為小寫。
代碼片段引入
它在 md.block.ruler.fence 之前加入了個(gè) snippet 規(guī)則,用作解析 <<< @/filepath 這樣的代碼:
const start = pos + 3
const end = state.skipSpacesBack(max, pos)
const rawPath = state.src.slice(start, end).trim().replace(/^@/, root)
const filename = rawPath.split(/[{:\s]/).shift()
const content = fs.existsSync(filename) ? fs.readFileSync(filename).toString() : 'Not found: ' + filename
它會(huì)把其中的文件路徑拿出來(lái)和 root 路徑拼起來(lái),然后讀取其中文件內(nèi)容。因?yàn)檫€可以解析 <<< @/test/markdown/fragments/snippet.js{2} 這樣附帶行高亮的代碼片段,所以需要用 split 截取真正的文件名。
結(jié)語(yǔ)
markdown 作為一門解釋型語(yǔ)言,可以幫助人們更好地描述一件事物。同時(shí),它又作為通往 HTML 的橋梁,最終可以生成美觀簡(jiǎn)約的頁(yè)面。
而 markdown-it 提供的解析器、渲染器以及插件系統(tǒng),更是讓開發(fā)者可以根據(jù)自己的想象力賦予 markdown 更多的魅力。