markdown 編譯原理

本文引至: please call me hrcss

由於本人平時寫做方式就是使用的markdown, 感受有些引擎解析快,有些慢. 但又迫不得已. 就像:html

我就喜歡你看不慣我,又幹不掉個人樣子node

因此, 這裏,相對markdown語法引擎作一個簡單分析。或者說,本身動手來寫一個micro-markdown-parser.
markdown 引擎其實並不複雜,只要你獲得了對應的regexp,而後替換一下HTML tag便可. 目前市面上流行的幾種markdown 解析器 無外乎就是: marker,markdown-js.
一開始,markdown是由John Gruber用Perl寫出來的語法解析器. 因爲md在後面過於火爆,出現了不一樣的支持引擎. 不過,後面在github上,提出了GFM (Github Flavored Markdown) 這一個標準以後. 大部分引擎的解析規範也獲得了統一.
最最基本的一個md引擎,應該須要可以解析: Inline HTML, Automatic paragraphs, headers, blockquotes, lists, code blocks, horizontal rules, links, emphasis, inline code and images 這幾種. 詳情,能夠參考: md features
接下來,咱們正式的 make a md parser.git

前期準備

關於md parser 最最基本的就是正則和exec方法. 先簡單說一下exec方法吧.github

正則的exec

exec是用來在特定str中,匹配指定正則的方法. 實際上可使用String.prototype.match代替.基本使用爲:面試

regexObj.exec(str)

返回值爲 array(匹配到) 和 null (沒有匹配到)
若是返回array則:正則表達式

  • [1]...[n]: 正則分組匹配到的內容.編程

  • index: 正則開始匹配到string的位置markdown

  • input: 原始的stringapp

具體的demo:

var re = /quick\s(brown).+?(jumps)/ig;
var result = re.exec('The Quick Brown Fox Jumps Over The Lazy Dog');

// 結果爲
[ 'Quick Brown Fox Jumps',
  'Brown',
  'Jumps',
  index: 4,
  input: 'The Quick Brown Fox Jumps Over The Lazy Dog' ]

而後是基本的正則匹配:

基本正則

正則表達式很容易去源碼裏翻一翻就找到了.

regexobject: {
    headline: /^(\#{1,6})([^\#\n]+)$/m,
    code: /\s\`\`\`\n?([^`]+)\`\`\`/g,
    hr: /^(?:([\*\-_] ?)+)\1\1$/gm,
    lists: /^((\s*((\*|\-)|\d(\.|\))) [^\n]+)\n)+/gm,
    bolditalic: /(?:([\*_~]{1,3}))([^\*_~\n]+[^\*_~\s])\1/g,
    links: /!?\[([^\]<>]+)\]\(([^ \)<>]+)( "[^\(\)\"]+")?\)/g,
    reflinks: /\[([^\]]+)\]\[([^\]]+)\]/g,
    smlinks: /\@([a-z0-9]{3,})\@(t|gh|fb|gp|adn)/gi,
    mail: /<(([a-z0-9_\-\.])+\@([a-z0-9_\-\.])+\.([a-z]{2,7}))>/gmi,
    tables: /\n(([^|\n]+ *\| *)+([^|\n]+\n))((:?\-+:?\|)+(:?\-+:?)*\n)((([^|\n]+ *\| *)+([^|\n]+)\n)+)/g,
    include: /[\[<]include (\S+) from (https?:\/\/[a-z0-9\.\-]+\.[a-z]{2,9}[a-z0-9\.\-\?\&\/]+)[\]>]/gi,
    url: /<([a-zA-Z0-9@:%_\+.~#?&\/=]{2,256}\.[a-z]{2,4}\b(\/[\-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)?)>/g
  }

本文參考的是一個教學用的markdown語法parser.github源碼 有興趣,能夠查看一下. 讀起來很是簡單.沒有過多的邏輯處理. 因此,這裏也是基於這個來進行講解的.

簡單匹配

最簡單的匹配應該算headline. 他的正則表達式爲: /^(\#{1,6})([^\#\n]+)$/m. 後面的m很是重要. 由於,全部的標題應該是寫在首行的,如:

# abc
## sub_abc

使用m flag 來做爲首行匹配標識符.完美~
而後,只須要進行一個循環便可.

var headling = /^(\#{1,6})([^\#\n]+)$/m
while ((stra = headline.exec(str)) !== null) {
      count = stra[1].length;
      str = str.replace(stra[0], '<h' + count + '>' + stra[2].trim() + '</h' + count + '>').trim();
    }

固然,這裏並不涉及到徹底性的處理. 最簡單的方式就是過濾字符串,不過過濾字符串也有不少方法. 最直接的就是replace直接替換.

function escape(html, encode) {
  return html
    .replace(!encode ? /&(?!#?\w+;)/g : /&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

這就算一個簡單的替換. 另外,還有一種是使用textNode內置的替換方案

// 使用textNode內置的替換引擎,將 < > $等字符替換. 但不會替換' 和 "
var escape = function(str) {
    'use strict';
    var div = document.createElement('div');
    div.appendChild(document.createTextNode(str));
    str = div.innerHTML;
    div = undefined;
    return str;
}

則上面的內容則能夠寫爲:

var headling = /^(\#{1,6})([^\#\n]+)$/m
while ((stra = headline.exec(str)) !== null) {
      count = stra[1].length;
      str = str.replace(stra[0], '<h' + count + '>' + escape(stra[2].trim()) + '</h' + count + '>').trim();
    }

實際上基於這點,咱們就能夠進行簡單的發散. 好比marked.js 根據正則提出了自定義化的匹配模式.

marked.js feature

一些正則細節和匹配細節,咱們這裏就不過多探討了, 由於處理的內容主要是\r\n ' ". 咱們這裏,簡單的來看一下marked.js 裏面的一些精華部分. 特別是他提出來的可自定義化的正則樣式. 即,Renderer方法.

// 官方提出的demo
var marked = require('marked');
var renderer = new marked.Renderer();

renderer.heading = function (text, level) {
  var escapedText = text.toLowerCase().replace(/[^\w]+/g, '-');

  return '<h' + level + '><a name="' +
                escapedText +
                 '" class="anchor" href="#' +
                 escapedText +
                 '"><span class="header-link"></span></a>' +
                  text + '</h' + level + '>';
},

console.log(marked('# heading+', { renderer: renderer }));

咱們能夠看一下他源碼裏面的思路:
首先,他有一個Renderer的構造函數:

function Renderer(options) {
  this.options = options || {};
}

接着就是綁定在prototype上面的方法:

Renderer.prototype.blockquote = function(quote) {
  return '<blockquote>\n' + quote + '</blockquote>\n';
};

可能有的童鞋會想,這裏他並無作什麼語法解析呢?
親, 請注意他的參數quote. 而後再看他的渲染內容,就一目瞭然. quote 是已經轉義事後匹配的內容.
咱們接着,來看一下調用方法:

// url (gfm)
 if (!this.inLink && (cap = this.rules.url.exec(src))) {
   src = src.substring(cap[0].length);
   text = escape(cap[1]);
   href = text;
   // out 這裏是指所有輸出的結果.
   out += this.renderer.link(href, null, text);
   continue;
 }

有童鞋可能又會疑問了, 你正則不是所有匹配的嗎? 這樣作不會遺漏信息嗎?
因此說, marked.js爲了實現自定義話的模式,犧牲了性能.咱們看一下他的正則表達式便可:

var block = {
  newline: /^\n+/,
  code: /^( {4}[^\n]+\n*)+/,
  fences: noop,
  hr: /^( *[-*_]){3,} *(?:\n+|$)/,
  heading: /^ *(#{1,6}) *([^\n]+?) *#* *(?:\n+|$)/,
  lheading: /^([^\n]+)\n *(=|-){2,} *(?:\n+|$)/,
  blockquote: /^( *>[^\n]+(\n(?!def)[^\n]+)*\n*)+/,
  list: /^( *)(bull) [\s\S]+?(?:hr|def|\n{2,}(?! )(?!\1bull )\n*|\s*$)/,
  html: /^ *(?:comment *(?:\n|\s*$)|closed *(?:\n{2,}|\s*$)|closing *(?:\n{2,}|\s*$))/,
  def: /^ *\[([^\]]+)\]: *<?([^\s>]+)>?(?: +["(]([^\n]+)[")])? *(?:\n+|$)/,
  paragraph: /^((?:[^\n]+\n?(?!hr|heading|lheading|blockquote|tag|def))+)\n*/,
  text: /^[^\n]+/
};

能夠看出,他沒有添加任何的pattern... 這就是marked.js精妙的地方. 因此, 上面的out 看起來,也並無什麼神奇的地方了:

out += this.renderer.link(href, null, text);

所以, 經過將renderer對象中方法的override. 形成自定義的效果. 這也是灰常好的. 另外,還有一點須要講解一下,就是marked.js構造的註釋替換的方法.

function replace(regex, opt) {
  regex = regex.source;
  opt = opt || '';
  return function self(name, val) {
    if (!name) return new RegExp(regex, opt);
    val = val.source || val;
    val = val.replace(/(^|[^\[])\^/g, '$1');
    regex = regex.replace(name, val);
    return self;
  };
}
// 看一下他的調用方法
// 相面的block.xxx 都是正則表達式,我這裏就不贅述了
block.paragraph = replace(block.paragraph)
  ('hr', block.hr)
  ('heading', block.heading)
  ('lheading', block.lheading)
  ('blockquote', block.blockquote)
  ('tag', '<' + block._tag)
  ('def', block.def)
  ();
// 實際上,這個方法運行的結果是生成一個新的正則表達式. 即,把上面用單詞的地方替換爲指定的正則
// 例如 paragraph 裏面的hr, heading
 paragraph: /^((?:[^\n]+\n?(?!hr|heading|lheading|blockquote|tag|def))+)\n*/

固然,也有其餘的實現方式。 只是marked.js在這裏作的比較完美.

marked 實際解析順序

前面提到了使用out+=的方式進行解析. 固然,可能會想到下列問題:
段落嵌套語法怎麼解析的呢?
這實際上,他在嵌套的語法層裏,並無作out+= 能夠看下列源碼:

// code
if (cap = this.rules.code.exec(src)) {
  src = src.substring(cap[0].length);
  cap = cap[0].replace(/^ {4}/gm, '');
  this.tokens.push({
    type: 'code',
    text: !this.options.pedantic
      ? cap.replace(/\n+$/, '')
      : cap
  });
  continue;
}

他在這裏傳了一個tokens, 而後 傳到外層這裏再次進行的解析.

Parser.prototype.tok = function() {
  switch (this.token.type) {
    case 'space': {
      return '';
    }
    case 'hr': {
      return this.renderer.hr();
    }
    ...
}

因此, marked.js爲了完成自定義化的解析真的是挖了一個很大的坑. 但相對於全局匹配在替換的模式來講, 這樣靈活性大一點。

flexibility + speed = const

ok, 如今咱們已經簡單的瞭解了大局方面的marked.js解析原理. 接下來,咱們來看一下比較難的code解析。

code 解析原理

若是隻是表層的code解析,很是簡單. 使用下面的正則表達式便可

code: /\s?\`\`\`\n?([^`]+)\`\`\`/g

可是,這樣僅僅只是替換出下列格式.

<pre>
    <code>
        ....
    </code>
<pre>

並無像下面這樣,帶上顏色的匹配.

var a =1;
var b =2;

簡單的替換原理也很好解釋.就是給指定的span添加上不一樣的class便可.

// 替換:

's'

// 生成span
<span class="str">'abc'</span>

它裏面的解析機制,主要就是根據不一樣的語法正則來添加不一樣的className.
具體,咱們能夠參照 highlight.js裏面的源碼:

function highlightBlock(block) {
    var node, originalStream, result, resultNode, text;
    var language = blockLanguage(block);
    text = node.textContent;
    ...
    result = language ? highlight(language, text, true) : highlightAuto(text);
    ...
  }

經過blockLanguage 找出指定的code的編程語言. 查找細節有一個方法比較重要:

function registerLanguage(name, language) {
  var lang = languages[name] = language(hljs);
  if (lang.aliases) {
  lang.aliases.forEach(function(alias) {aliases[alias] = name;});
  }
}

該方法用來手動將language的配置文件掛載到裏面。 咱們看一看js的配置文件

/*
Language: JavaScript
Category: common, scripting
*/

function(hljs) {
  return {
    aliases: ['js', 'jsx'],
    keywords: {
      keyword:
        'in of if for while finally var new function do return void else break catch ' +
        'instanceof with throw case default try this switch continue typeof delete ' +
        'let yield const export super debugger as async await static ' +
        // ECMAScript 6 modules import
        'import from as'
      ,
      literal:
        'true false null undefined NaN Infinity',
      built_in:
        'eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent ' +
    ...
}

而後經過指定的正則來進行匹配和替換. 因此, 通常的md parser引擎解析並不會自帶code解析, 由於是在太複雜了... 編程語言這麼多.. 這麼搞的玩. 因此, highlight 本身自定義了一套 common 機制. 一方, 沒有傳入指定language的狀況.

hljs.COMMENT = function (begin, end, inherits) {
    var mode = hljs.inherit(
      {
        className: 'comment',
        begin: begin, end: end,
        contains: []
      },
      inherits || {}
    );
    mode.contains.push(hljs.PHRASAL_WORDS_MODE);
    mode.contains.push({
      className: 'doctag',
      begin: "(?:TODO|FIXME|NOTE|BUG|XXX):",
      relevance: 0
    });
    return mode;
  };
  hljs.C_LINE_COMMENT_MODE = hljs.COMMENT('//', '$');
  hljs.C_BLOCK_COMMENT_MODE = hljs.COMMENT('/\\*', '\\*/');
  hljs.HASH_COMMENT_MODE = hljs.COMMENT('#', '$');
  hljs.NUMBER_MODE = {
    className: 'number',
    begin: hljs.NUMBER_RE,
    relevance: 0
  };
  hljs.C_NUMBER_MODE = {
    className: 'number',
    begin: hljs.C_NUMBER_RE,
    relevance: 0
  };
  hljs.BINARY_NUMBER_MODE = {
    className: 'number',
    begin: hljs.BINARY_NUMBER_RE,
    relevance: 0
  };
  hljs.CSS_NUMBER_MODE = {
    className: 'number',
    begin: hljs.NUMBER_RE + '(' +
      '%|em|ex|ch|rem'  +
      '|vw|vh|vmin|vmax' +
      '|cm|mm|in|pt|pc|px' +
      '|deg|grad|rad|turn' +
      '|s|ms' +
      '|Hz|kHz' +
      '|dpi|dpcm|dppx' +
      ')?',
    relevance: 0
  };

不說了,最近被面試官調戲,心情比較差... 在博客最後放一個雞湯.

mdzz, 說好約定的時間呢? 連時間都不遵照的面試官...請自重

相關文章
相關標籤/搜索
本站公眾號
   歡迎關注本站公眾號,獲取更多信息