markdown-it 外掛如何寫(二)

冴羽發表於2022-01-28

前言

《一篇帶你用 VuePress + Github Pages 搭建部落格》中,我們使用 VuePress 搭建了一個部落格,最終的效果檢視:TypeScript 中文文件

在搭建部落格的過程中,我們出於實際的需求,在《VuePress 部落格優化之擴充 Markdown 語法》中講解了如何寫一個 markdown-it外掛,又在 《markdown-it 原理解析》中講解了 markdown-it的執行原理,本篇我們將講解具體的實戰程式碼,幫助大家更好的寫外掛。

Parse

markdown-it的渲染過程分為兩部分,ParseRender,如果我們要實現新的 markdown 語法,舉個例子,比如我們希望解析 @ header<h1>header</h1>,就可以從 Parse 過程入手。

markdown-it 的官方文件裡可以找到自定義 parse 規則的方式,那就是通過 Ruler 類:

var md = require('markdown-it')();

md.block.ruler.before('paragraph', 'my_rule', function replace(state) {
  //...
});

這句話的意思是指在 markdown-it 的解析 block 的一組規則中,在 paragraph 規則前插入一個名為 my_rule 的自定義規則,我們慢慢來解釋。

首先是 md.block.ruler,除此之外,還有 md.inline.rulermd.core.ruler可以自定義其中的規則。

然後是 .before,檢視 Ruler 相關的 API,還有 afteratdisableenable等方法,這是因為規則是按照順序執行的,某一規則的改變可能會影響其他規則。

接著是 paragraph,我怎麼知道插入在哪個規則前面或者後面呢?這就需要你看原始碼了,並沒有文件給你講這個……

如果是md.block,檢視 parse_block.js,如果是md.inline,檢視 parse_inline.js,如果是 md.core,檢視 parse_core.js,我們以md.block為例,可以看到原始碼裡寫了這些規則:

var _rules = [
  // First 2 params - rule name & source. Secondary array - list of rules,
  // which can be terminated by this one.
  [ 'table',      require('./rules_block/table'),      [ 'paragraph', 'reference' ] ],
  [ 'code',       require('./rules_block/code') ],
  [ 'fence',      require('./rules_block/fence'),      [ 'paragraph', 'reference', 'blockquote', 'list' ] ],
  [ 'blockquote', require('./rules_block/blockquote'), [ 'paragraph', 'reference', 'blockquote', 'list' ] ],
  [ 'hr',         require('./rules_block/hr'),         [ 'paragraph', 'reference', 'blockquote', 'list' ] ],
  [ 'list',       require('./rules_block/list'),       [ 'paragraph', 'reference', 'blockquote' ] ],
  [ 'reference',  require('./rules_block/reference') ],
  [ 'html_block', require('./rules_block/html_block'), [ 'paragraph', 'reference', 'blockquote' ] ],
  [ 'heading',    require('./rules_block/heading'),    [ 'paragraph', 'reference', 'blockquote' ] ],
  [ 'lheading',   require('./rules_block/lheading') ],
  [ 'paragraph',  require('./rules_block/paragraph') ]
];

最後是function replace(state),這裡函式的引數其實不止有 state,我們檢視任何一個具體規則的 parse 程式碼,就比如 heading.js

module.exports = function heading(state, startLine, endLine, silent) {
  var ch, level, tmp, token,
      pos = state.bMarks[startLine] + state.tShift[startLine],
      max = state.eMarks[startLine];
    
  // ...
};

可以看出除了 state,還有 startLineendLinesilent,而具體這其中的程式碼怎麼寫,其實最好的方式就是參考這些已經實現的程式碼。

例項講解

接下來我們以解析 @ header<h1>header</h1>為例,講解其中涉及的程式碼,這是要渲染的內容:

var md = window.markdownit();
// md.block.ruler.before(...)

var result = md.render(`@ header
contentTwo
`);

console.log(result);

正常它的渲染結果是:

<p>@ header
contentTwo</p>

現在期望的渲染結果是:

<h1>header</h1>
<p>contentTwo</p>

我們來看看如何實現,先參照 header.js 的程式碼依葫蘆畫瓢:

md.block.ruler.before('paragraph','@header',function(state, startLine, endLine, silent){
  var ch, level, tmp, token,
      pos = state.bMarks[startLine] + state.tShift[startLine],
      max = state.eMarks[startLine];
  
  //...
})

parse 的過程是根據換行符逐行掃描的,所以每一行的內容都會執行我們這個自定義函式進行匹配,函式支援傳入四個引數,其中,state 記錄了各種狀態資料,startLine 表示本次的起始行數,而 endLine 表示總的結束行數。

我們列印下 state`startLineendLine` 等資料:

md.block.ruler.before('paragraph','@header',function(state, startLine, endLine, silent){
  var ch, level, tmp, token,
      pos = state.bMarks[startLine] + state.tShift[startLine],
      max = state.eMarks[startLine];
  
  console.log(JSON.parse(JSON.stringify(state)), startLine, endLine);
})

這是列印的結果:

其中 state 的內容我們簡化下展示出來:

{
    "src": "@ header\ncontentTwo\n",
    "md": {...},
    "env": {...},
    "tokens": [...],
    "bMarks": [0, 9, 20],
    "eMarks": [8, 19, 20],
    "tShift": [0, 0, 0],
    "line": 0
}

state 中這些欄位的具體含義可以檢視 state_block.js 檔案,這其中:

  • bMarks 表示每一行的起始位置
  • eMarks 表示每一行的終止位置
  • tShift 表示每一行第一個非空格字元的位置

我們看下 pos 的計算邏輯為 state.bMarks[startLine] + state.tShift[startLine],其中 startLine 是 0,所以 pos = 0 + 0 = 0

再看下 max 的計算邏輯為 state.eMarks[startLine],所以max = 8

從這也可以看出,其實 pos 就是這行字元的初始位置,max 這行字元的結束位置,通過 posmax,我們可以擷取出這行字串:

md.block.ruler.before('paragraph','@header',function(state, startLine, endLine, silent){
  var ch, level, tmp, token,
      pos = state.bMarks[startLine] + state.tShift[startLine],
      max = state.eMarks[startLine];
  
          console.log(JSON.parse(JSON.stringify(state)), startLine, endLine);
          let text = state.src.substring(pos, max);
          console.log(text);
  
          state.line = startLine + 1;
            return true
})

列印結果為:

在程式碼裡我們加入了state.line = startLine + 1;return true,這是為了進入到下一行的遍歷之中。

如果我們能取出每次用於判斷的字串,那我們就可以進行正則匹配,如果匹配,就自定義 tokens,剩下的邏輯很簡單,我們直接給出最後的程式碼:

md.block.ruler.before('paragraph', 'myplugin', function (state,startLine,endLine) {
  var ch, level, tmp, token,
      pos = state.bMarks[startLine] + state.tShift[startLine],
      max = state.eMarks[startLine];
      ch  = state.src.charCodeAt(pos);

      if (ch !== 0x40/*@*/ || pos >= max) { return false; }
      
      let text = state.src.substring(pos, max);
      let rg = /^@\s(.*)/;
      let match = text.match(rg);

      if (match && match.length) {
        let result = match[1];
        token = state.push('heading_open', 'h1', 1);
        token.markup = '@';
        token.map = [ startLine, state.line ];

        token = state.push('inline', '', 0);
        token.content = result;
        token.map = [ startLine, state.line ];
        token.children = [];

        token = state.push('heading_close', 'h1', -1);
        token.markup = '@';

        state.line = startLine + 1;
        return true;
      }
})

至此,就實現了預期的效果:

系列文章

部落格搭建系列是我至今寫的唯一一個偏實戰的系列教程,預計 20 篇左右,講解如何使用 VuePress 搭建、優化部落格,並部署到 GitHub、Gitee、私有伺服器等平臺。全系列文章地址:https://github.com/mqyqingfeng/Blog

微信:「mqyqingfeng」,加我進冴羽唯一的讀者群。

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。

相關文章