markdown-it 原理淺析

雲峰yf發表於2019-05-01

前言

最近使用 markdown-it 比較多,也開發了一些外掛,在這個過程中對原始碼進行了研讀,最終寫了這篇文章。需要了解細節的讀者可以自行閱讀文件

此文分為兩個部分:原理剖析和原理應用(編寫外掛)。

markdown-it 原理

輸入一串 markdown 程式碼,最後得到一串 html 程式碼,整體流程如下:

1.png

我們以一個簡單的例子來解釋整個流程:​# 我是一個例子​  -> ​<h1>我是一個例子</h1>​

首先,它會被解析器拿到,經過各個解析規則處理後得到一個 token 流,接著這個 token 流被渲染器拿到,經過各個渲染規則處理後逐步拼接成一個 html 字串。

解析器

markdown-it 內建了七個核心規則,在上圖我對解析規則使用了虛線,因為它們是可以被啟用/禁用的。我們這篇文章只來聊聊最核心的兩個規則:block 和 inline。

規範指出:

我們可以將一篇 Markdown 文件視為一系列塊,是一種結構化的元素,如段落,塊引用,列表,標題,規則和程式碼塊。一些塊(如塊引號和列表項)可以包含其他塊; 其他(如標題和段落)包含內聯內容,如文字,連結,強調文字,影像,行內程式碼等。

塊結構的解析優先順序始終高於內聯結構。這意味著解析可以分兩步進行: 1.識別 markdown 文件的塊結構;  2.將段落,標題和其他塊結構中的文字行,作為內聯結構解析。

注意,第一步需要按順序處理行,但第二步可以並行化,因為一個塊元素的內聯解析不會影響任何其他塊的內聯解析。

塊分為兩種型別:容器塊葉子塊,容器塊可以包含其他塊,但葉子塊不能包含其他塊。

具體解析時,會圍繞著 line 和 character 兩個維度來解析。

對於每一行來說,解釋的結果有以下三種:

  1. 用來關閉一個或多個塊結構。

  2. 用來建立一個或多個新塊結構,作為最後開啟的塊結構的子節點。

  3. 可以將文字新增到樹上剩餘的最後(最深的)開啟的塊結構上。

對於我們這個例子,會先建立一個 heading 塊,然後將文字內容新增到這個塊上。下一行沒有內容,於是塊關閉。

字元包括非空白字元和空格(​U+0020​),製表符 (​U+0009​),換行符(​U+000A​),行列表(​U+000B​),換頁(​U+000C​)或回車(​U+000D​)這些空白字元。這裡我們不做展開。

這期間會接觸到的規則有 block、inline、heading、text。

  1. block 規則,會用來解析 ​# 我是一個例子​
  • 先進入 tokenize 函式,內含十一個 block 規則。

  • heading 規則

  • 得到 heading_open 、inline、 heading_close 三個 token

  1. inline 規則,會用來解析 ​我是一個例子​
  • 先進入 parse 函式,內含四個 inline 規則

  • text 規則

  • 得到 text 的 token

解析完畢,我們得到了 3 + 1 個 token:

2.png

token 流

這裡我們得到的結果不是一顆 AST 樹,而是一個陣列,markdown-it 稱之為 token 流。為什麼呢?

官方解釋是:

  • Tokens 是一個簡單的陣列。(AST 是一個物件)

  • 開啟的標籤和關閉的標籤可以隔離。

  • 將“內聯容器(inline container)”作為一種特殊的 block token 物件。它有巢狀的 tokens,如粗體,斜體,文字等等。

這樣做有什麼好處呢?這樣就可以並行處理 block 和 inline 型別的 token 了。

生成 token 流後,它們就被會傳遞給 renderer

渲染器

它會遍歷所有 token,將每個 token 傳遞給與 token 的 type 屬性同名的規則。markdown-it 內建了九種規則:圍欄、行內程式碼、程式碼塊、html 塊、行內 html、圖片、硬換行、軟換行、文字。

type 屬性不在內建規則的 token 將會被被傳入 renderToken 中當一個普通 token 處理,這裡不作展開。

回到我們的例子中來:

heading_open 會被渲染成<h1>

inline 中的 text 會被渲染成我是一個例子

heading_close 會被渲染成</h1>

markdown-it 外掛

一些 markdown-it 外掛就利用了上述的原理。

markdown-it-container

這個外掛可以讓你支援內容塊:比較常見的是提示、警告、危險。用於強調一塊特定內容。

markdown-it 原理淺析

這是如何實現的呢?我們可以根據之前的介紹推測一個內容塊的 token 流:

第一行和第三行有 block 型的 token,一個代表 open,一個代表 close。第二行是 inline 型的 token,其中的內容是 inline 型的。

由於內容塊中是 inline 型別,所以圍欄、行內程式碼、程式碼塊、html 塊、行內 html、圖片、硬換行、軟換行、文字都是支援的。

實際上,我們會逐行掃描,找到匹配::: tip這樣的內容塊語法,將它作為一個塊結構開始進行解析,直到有:::的行結束。其中的每一行,都將解析為 paragraph_open、inline、paragraph_close。

解析後的 token 流最後分別渲染<div>、若干 p 標籤、</div>

markdown-it-anchor

這個外掛可以對標題進行錨點抽取,以便閱讀文件時能快速定位位置。

這裡也可以推測一下,是不是往原本是 heading_open type 的 token 之前插入了一個 token 呢?這個 token 渲染出來就是錨點。

實際上,的確是插入了 token,但不止一個,因為錨點是可點選的,所以實際上是一個 a 連結,也就是 link_open、inline、link_close 三個 token。而且也不是插入在 heading_open 之前,而是 heading_open 和 heading_close 之間的 inline 子元素裡了,因為 ​#​ 是和 ​Markdown 語法​平級的。

注意事項: 1.因為標題可能是@#$等特殊字元,會造成 url 雜湊無效,所以需要對錨點的雜湊值轉義。 2.可能會出現重名的標題,所以需要對雜湊進行標記

給連結新增屬性

官方有一個寫外掛的例子:新增 target="_blank" 屬性到所有連結。

有兩種方式:

  1. 修改渲染器規則
// 如果覆蓋,或者是對預設渲染器的代理,則記住老的渲染器。
var 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) {
  // 如果你確認其他的外掛不能新增 `target` - 放棄以下檢查:
  var aIndex = tokens[idx].attrIndex('target');

  if (aIndex < 0) {
    tokens[idx].attrPush(['target', '_blank']); // 新增新屬性
  } else {
    tokens[idx].attrs[aIndex][1] = '_blank';    // 替換已經存在的屬性值
  }

  // 傳遞 token 到預設的渲染器。
  return defaultRender(tokens, idx, options, env, self);
};
複製程式碼
  1. 修改 token
var iterator = require('markdown-it-for-inline');

var md = require('markdown-it')()
            .use(iterator, 'url_new_win', 'link_open', function (tokens, idx) {
              var aIndex = tokens[idx].attrIndex('target');

              if (aIndex < 0) {
                tokens[idx].attrPush(['target', '_blank']);
              } else {
                tokens[idx].attrs[aIndex][1] = '_blank';
              }
            });
複製程式碼

高亮

這裡官方文件給出的例子是利用了 highlight.js,涉及到的技術比較複雜(主要是編譯各種語言語法樹),在此不展開講解。

結語

markdown-it 作為一款經典的 js 解析 markdown 的庫,其中思想和設計都可以細細揣摩,回味久久。

相關文章