在此係列文章的第一篇,咱們介紹了 Vuepress 如何讓 Markdown 支持 Vue 組件的,但沒有提到非 Vue 組件的其餘部分如何被解析。html
今天,咱們就來看看 Vuepress 是如何利用 markdown-it 來解析 markdown 代碼的。vue
markdown-it 是一個輔助解析 markdown 的庫,能夠完成從 # test
到 <h1>test</h1>
的轉換。webpack
它同時支持瀏覽器環境和 Node 環境,本質上和 babel 相似,不一樣之處在於,babel 解析的是 JavaScript。git
說到解析,markdown-it 官方給了一個在線示例,可讓咱們直觀地獲得 markdown 通過解析後的結果。好比仍是拿 # test
舉例,會獲得以下結果:github
[
{
"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: web
咱們也能夠手動執行下面代碼獲得一樣的結果:npm
const md = new MarkdownIt()
let tokens = md.parse('# test')
console.log(tokens)
複製代碼
markdown-it 提供了三種模式:commonmark、default、zero。分別對應最嚴格、GFM、最寬鬆的解析模式。json
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 find CommonMark 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。
vuepress 藉助了 markdown-it 的諸多社區插件,如高亮代碼、代碼塊包裹、emoji 等,同時也自行編寫了不少 markdown-it 插件,如識別 vue 組件、內外鏈區分渲染等。
本文寫自 2018 年國慶期間,對應 vuepress 代碼版本爲 v1.0.0-alpha.4。
源碼 主要作了下面五件事:
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 做爲一個全局變量了,存儲各個插件要用到的數據。
就作了一件事:替換默認的 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
意味着標籤正在關閉。if (lang === 'vue' || lang === 'html') {
lang = 'markup'
}
複製代碼
function wrap (code, lang) {
if (lang === 'text') {
code = escapeHtml(code)
}
return `<pre v-pre class="language-${lang}"><code>${code}</code></pre>`
}
複製代碼
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 插件提供了鉤子。
這段代碼最初是爲了解決錨點中帶中文或特殊字符沒法正確跳轉的問題。
處理的非 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 更多的魅力。