本文引至: please call me hr
因为本人平时写作方式就是使用的markdown, 感觉有些引擎解析快,有些慢. 但又无可奈何. 就像:
我就喜欢你看不惯我,又干不掉我的样子
所以, 这里,相对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.
前期准备
关于md parser 最最基本的就是正则和exec方法. 先简单说一下exec方法吧.
正则的exec
exec是用来在特定str中,匹配指定正则的方法. 实际上可以使用String.prototype.match代替.基本使用为:
regexObj.exec(str)
返回值为 array(匹配到) 和 null (没有匹配到)
如果返回array则:
[1]...[n]: 正则分组匹配到的内容.
index: 正则开始匹配到string的位置
input: 原始的string
具体的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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
这就算一个简单的替换. 另外,还有一种是使用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, 说好约定的时间呢? 连时间都不遵守的面试官...请自重
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。