使用 markdown-it 解析 markdown 程式碼(讀 VuePress 三)
在此係列文章的第一篇,我們介紹了 Vuepress 如何讓 Markdown 支援 Vue 元件的,但沒有提到非 Vue 元件的其他部分如何被解析。
今天,我們就來看看 Vuepress 是如何利用 markdown-it 來解析 markdown 程式碼的。
markdown-it 簡介
markdown-it 是一個輔助解析 markdown 的庫,可以完成從 # test
到 <h1>test</h1>
的轉換。
它同時支援瀏覽器環境和 Node 環境,本質上和 babel 類似,不同之處在於,babel 解析的是 JavaScript。
說到解析,markdown-it 官方給了一個線上示例,可以讓我們直觀地得到 markdown 經過解析後的結果。比如還是拿 # 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 } ] 複製程式碼
經過 tokenizes 後,我們得到了一個 tokens:

我們也可以手動執行下面程式碼得到同樣的結果:
const md = new MarkdownIt() let tokens = md.parse('# test') console.log(tokens) 複製程式碼
主要 API 介紹
模式
markdown-it 提供了三種模式:commonmark、default、zero。分別對應最嚴格、 ofollow,noindex">GFM 、最寬鬆的解析模式。
解析
markdown-it 的解析規則大體上分為塊(block)和內聯(inline)兩種。具體可體現為 MarkdownIt.block
對應的是解析塊規則的 ParserBlock , MarkdownIt.inline
對應的是解析內聯規則的 ParserInline , MarkdownIt.renderer.render
和 MarkdownIt.renderer.renderInline
分別對應按照塊規則和內聯規則生成 HTML 程式碼。
規則
在 MarkdownIt.renderer
中有一個特殊的屬性:rules,它代表著對於 token 們的渲染規則,可以被使用者更新或擴充套件:
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 的規則。
外掛系統
markdown-it 官方說過:
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 findCommonMark forum a useful read to understand us better.
一言以蔽之,就是 markdown-it 只做純粹的 markdown 解析,想要更多的功能你得自己寫外掛。
所以,他們提供了一個 API:MarkdownIt.use
它可以將指定的外掛載入到當前的解析器例項中:
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。
更多資訊
可以訪問我國慶期間翻譯的 中文文件 ,或者官方API 文件。
vuepress 中的應用
vuepress 藉助了 markdown-it 的諸多社群外掛,如高亮程式碼、程式碼塊包裹、emoji 等,同時也自行編寫了很多 markdown-it 外掛,如識別 vue 元件、內外鏈區分渲染等。
本文寫自 2018 年國慶期間,對應 vuepress 程式碼版本為 v1.0.0-alpha.4。
入口
原始碼 主要做了下面五件事:
- 使用社群外掛,如 emoji 識別、錨點、toc。
- 使用自定義外掛,稍後詳細說明。
- 使用 markdown-it-chain 支援鏈式呼叫 markdown-it,類似我在 第二篇文章 提到的 webpack-chain。
- 引數可以傳 beforeInstantiate 和 afterInstantiate 這兩個鉤子,這樣方便暴露 markdown-it 例項給外部。
- 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 } } } 複製程式碼
相當於讓 __data 作為一個全域性變量了,儲存各個外掛要用到的資料。
識別 vue 元件
就做了一件事:替換預設的 htmlBlock 規則,這樣就可以在根級別使用自定義的 vue 元件了。
module.exports = md => { md.block.ruler.at('html_block', htmlBlock) } 複製程式碼
這個 htmlBlock 函式和原生的 markdown-it 的 html_block 關鍵區別在哪呢?
答案是在 HTML_SEQUENCES 這個正則數組裡添加了兩個元素:
// PascalCase Components [/^<[A-Z]/, />/, true], // custom elements with hyphens [/^<\w+\-/, />/, true], 複製程式碼
很明顯,這就是用來匹配帕斯卡寫法(如 <Button/>
)和連字元(如 <button-1/>
)寫法的元件的。
內容塊
這個元件實際上是藉助了社群的 markdown-it-container 外掛,在此基礎上定義了 tip、warning、danger、v-pre 這四種內容塊的 render 函式:
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` } } 複製程式碼
這裡需要說明一下的是 token 的兩個屬性。
-
info 三個反引號後面跟的那個字串。
-
nesting 屬性:
1 0 -1
高亮程式碼
- 藉助了prismjs 這個庫
- 將 vue 和 html 看做是同一種語言:
if (lang === 'vue' || lang === 'html') { lang = 'markup' } 複製程式碼
- 對語言縮寫做了相容,如 md、ts、py
- 使用 wrap 函式對生成的高亮程式碼再做一層包裝:
function wrap (code, lang) { if (lang === 'text') { code = escapeHtml(code) } return `<pre v-pre class="language-${lang}"><code>${code}</code></pre>` } 複製程式碼
高亮程式碼行
Lines.js" rel="nofollow,noindex">原始碼
- 在 別人的程式碼 基礎上修改的。
- 重寫了 md.renderer.rules.fence 方法,關鍵是藉助一個正則判斷獲取要高亮的程式碼行們:
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 規則:
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 標籤儲存在 __data 這個偽全域性變數裡。這部分資料會在 markdownLoader 中用到。
行號
重寫 md.renderer.rules.fence 規則,通過換行符的數量來推算程式碼行數,並再包裹一層:
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 複製程式碼
內外鏈區分
一個 a 連結,可能是跳往站內的,也有可能是跳往站外的。vuepress 將這兩種連結做了一個區分,最終外鏈會比內鏈多渲染出一個圖示:

要實現這點,vuepress 重寫了 md.renderer.rules.link_open 和 md.renderer.rules.link_close 這兩個規則。
先看 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 便是外鏈的標誌位,這時如果它為真,則直接設定 token 的屬性即可,如果 isSourceLink 為真,則代表傳入了個內鏈,整個 token 將會被替換成 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 結尾的有效連結。
再來看 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) 複製程式碼
很明顯,內鏈渲染 router-link 標籤,外鏈渲染 OutboundLink 標籤,也就是加了那個小圖示的連結元件。
程式碼塊包裹
這個外掛重寫了 md.renderer.rules.fence 方法,用來對 <pre>
標籤再做一次包裹:
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-->` } 複製程式碼
將圍欄程式碼拆成四個部分:beforebegin、afterbegin、beforeend、afterend。相當於給使用者再自定義 markdown-it 外掛提供了鉤子。
錨點非 ascii 字元處理
這段程式碼最初是為了解決錨點中帶中文或特殊字元無法正確跳轉的問題。
處理的非 acsii 字元依次是:變音符號 -> C0控制符 -> 特殊字元 -> 連續出現2次以上的短槓(-) -> 用作開頭或結尾的短杆。
最後將開頭的數字加上下劃線,全部轉為小寫。
程式碼片段引入
它在 md.block.ruler.fence 之前加入了個 snippet 規則,用作解析 <<< @/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 複製程式碼
它會把其中的檔案路徑拿出來和 root 路徑拼起來,然後讀取其中檔案內容。因為還可以解析 <<< @/test/markdown/fragments/snippet.js{2}
這樣附帶行高亮的程式碼片段,所以需要用 split 擷取真正的檔名。
結語
markdown 作為一門解釋型語言,可以幫助人們更好地描述一件事物。同時,它又作為通往 HTML 的橋樑,最終可以生成美觀簡約的頁面。
而 markdown-it 提供的解析器、渲染器以及外掛系統,更是讓開發者可以根據自己的想象力賦予 markdown 更多的魅力。