前言
最近使用 markdown-it 比較多,也開發了一些外掛,在這個過程中對原始碼進行了研讀,最終寫了這篇文章。需要了解細節的讀者可以自行閱讀文件。
此文分為兩個部分:原理剖析和原理應用(編寫外掛)。
markdown-it 原理
輸入一串 markdown 程式碼,最後得到一串 html 程式碼,整體流程如下:
我們以一個簡單的例子來解釋整個流程:# 我是一個例子
-> <h1>我是一個例子</h1>
首先,它會被解析器拿到,經過各個解析規則處理後得到一個 token 流,接著這個 token 流被渲染器拿到,經過各個渲染規則處理後逐步拼接成一個 html 字串。
解析器
markdown-it 內建了七個核心規則,在上圖我對解析規則使用了虛線,因為它們是可以被啟用/禁用的。我們這篇文章只來聊聊最核心的兩個規則:block 和 inline。
規範指出:
我們可以將一篇 Markdown 文件視為一系列塊,塊是一種結構化的元素,如段落,塊引用,列表,標題,規則和程式碼塊。一些塊(如塊引號和列表項)可以包含其他塊; 其他(如標題和段落)包含內聯內容,如文字,連結,強調文字,影像,行內程式碼等。
塊結構的解析優先順序始終高於內聯結構。這意味著解析可以分兩步進行: 1.識別 markdown 文件的塊結構; 2.將段落,標題和其他塊結構中的文字行,作為內聯結構解析。
注意,第一步需要按順序處理行,但第二步可以並行化,因為一個塊元素的內聯解析不會影響任何其他塊的內聯解析。
具體解析時,會圍繞著 line 和 character 兩個維度來解析。
對於每一行來說,解釋的結果有以下三種:
-
用來關閉一個或多個塊結構。
-
用來建立一個或多個新塊結構,作為最後開啟的塊結構的子節點。
-
可以將文字新增到樹上剩餘的最後(最深的)開啟的塊結構上。
對於我們這個例子,會先建立一個 heading 塊,然後將文字內容新增到這個塊上。下一行沒有內容,於是塊關閉。
字元包括非空白字元和空格(U+0020),製表符 (U+0009),換行符(U+000A),行列表(U+000B),換頁(U+000C)或回車(U+000D)這些空白字元。這裡我們不做展開。
這期間會接觸到的規則有 block、inline、heading、text。
- block 規則,會用來解析
# 我是一個例子
-
先進入 tokenize 函式,內含十一個 block 規則。
-
heading 規則
-
得到 heading_open 、inline、 heading_close 三個 token
- inline 規則,會用來解析
我是一個例子
-
先進入 parse 函式,內含四個 inline 規則
-
text 規則
-
得到 text 的 token
解析完畢,我們得到了 3 + 1 個 token:
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
這個外掛可以讓你支援內容塊:比較常見的是提示、警告、危險。用於強調一塊特定內容。
這是如何實現的呢?我們可以根據之前的介紹推測一個內容塊的 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" 屬性到所有連結。
有兩種方式:
- 修改渲染器規則
// 如果覆蓋,或者是對預設渲染器的代理,則記住老的渲染器。
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);
};
複製程式碼
- 修改 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 的庫,其中思想和設計都可以細細揣摩,回味久久。