讀 VuePress(三)使用 markdown-it 解析 markdown 代碼

https://www.jianshu.com/p/a95c04a68d14

前言

在此係列文章的第一篇,我們介紹了 Vuepress 如何讓 Markdown 支持 Vue 組件的,但沒有提到非 Vue 組件的其他部分如何被解析。

今天,我們就來看看 Vuepress 是如何利用 markdown-it 來解析 markdown 代碼的。

markdown-it 簡介

markdown-it 是一個輔助解析 markdown 的庫,可以完成從 # test<h1>test</h1> 的轉換。

它同時支持瀏覽器環境和 Node 環境,本質上和 babel 類似,不同之處在於,babel 解析的是 JavaScript。

說到解析,實際上稱爲解釋(interpreter)或者編譯(compiler)更爲令人熟悉。總歸繞不開詞法分析和語法分析這兩個過程。

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
  }
]

詞法分析,說白了,就是把一段代碼拆分成若干個基本單元(token),這些基本單元又可以進一步分類。這個過程稱之爲 tokenizes。

語法分析,其實就是將最終要生成的代碼用一顆樹(ast)來表示,其中每個節點都是我們通過詞法分析得到的 token 對象。顯而易見,我們得到了一顆這樣的 AST:

 

ast

我們也可以手動執行下面代碼得到同樣的結果:

const md = new MarkdownIt()
let tokens = md.parse('# test')
console.log(tokens)

主要 API 介紹

模式

markdown-it 提供了三種模式:commonmark、default、zero。分別對應最嚴格、GFM、最寬鬆的解析模式。

解析

markdown-it 的解析規則大體上分爲塊(block)和內聯(inline)兩種。具體可體現爲 MarkdownIt.block 對應的是解析塊規則的 ParserBlockMarkdownIt.inline 對應的是解析內聯規則的 ParserInlineMarkdownIt.renderer.renderMarkdownIt.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。

更多信息

可以訪問我國慶期間翻譯的中文文檔,或者官方 API 文檔

vuepress 中的應用

vuepress 藉助了 markdown-it 的諸多社區插件,如高亮代碼、代碼塊包裹、emoji 等,同時也自行編寫了很多 markdown-it 插件,如識別 vue 組件、內外鏈區分渲染等。

相關源碼

本文寫自 2018 年國慶期間,對應 vuepress 代碼版本爲 v1.0.0-alpha.4。

入口

源碼
主要做了下面五件事:

  1. 使用社區插件,如 emoji 識別、錨點、toc。
  2. 使用自定義插件,稍後詳細說明。
  3. 使用 markdown-it-chain 支持鏈式調用 markdown-it,類似我在第二篇文章提到的 webpack-chain。
  4. 參數可以傳 beforeInstantiate 和 afterInstantiate 這兩個鉤子,這樣方便暴露 markdown-it 實例給外部。
  5. 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 的兩個屬性。

  1. info
    三個反引號後面跟的那個字符串。

  2. nesting 屬性:

  • 1 意味着標籤打開。
  • 0 意味着標籤是自動關閉的。
  • -1 意味着標籤正在關閉。

高亮代碼

源碼

  1. 藉助了 prismjs 這個庫
  2. 將 vue 和 html 看做是同一種語言:
if (lang === 'vue' || lang === 'html') {
    lang = 'markup'
}
  1. 對語言縮寫做了兼容,如 md、ts、py
  2. 使用 wrap 函數對生成的高亮代碼再做一層包裝:
function wrap (code, lang) {
  if (lang === 'text') {
    code = escapeHtml(code)
  }
  return `<pre v-pre class="language-${lang}"><code>${code}</code></pre>`
}

高亮代碼行

源碼

  1. 別人的代碼基礎上修改的。
  2. 重寫了 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">&nbsp;</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 將這兩種鏈接做了一個區分,最終外鏈會比內鏈多渲染出一個圖標:

 

link.png

要實現這點,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 更多的魅力。 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章