「这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战」
是不是为产品爸爸希望在 Markdown 文章中插入视频音乐等媒体元素而烦恼?本文为你解答一切。
为什么要创造个性化的 Markdown 语法?
开始这个话题前,我们必须先介绍一下我们公司的产品——百瓶( web.billionbottle.com/download )。它是一个专业的威士忌社区,在里面你可以看到由我们优秀的写手以及 KOL 所撰写的文章,涵盖了威士忌历史、品牌、测评等一系列话题。
这些文章都是由我们独立开发的文章编辑器进行编写的,文章保存的实际内容是 Markdown。当有一天,产品爸爸发现其他平台的编辑器能插入丰富多彩的媒体元素,比如视频、音乐、名片等等,这时候产品爸爸便问我们开发,我们能不能在我们的文章中也做到类似的效果。这可把我们难住了,传统的 Markdown 哪里会有业务性这么强的语法。但是人家都已经做到这种程度了,一个成熟的开发怎么能说不行,一个字,开干。
你是否也遇到过类似的需求?在所有已存文章都已经是 Markdown 内容的情况下,我们做不到在修改内容形式上寻求突破,还得在创造新语法的道路上走到底。好在我们不需要从零开始造轮子,一款强大的 Markdown 渲染利器为我们提供了可能,它就是「Marked」( marked.js.org )。
简单介绍 Marked 的用法
我们有一份简单的 Markdown 内容:
# Hello Marked
My name is Juner.
然后我们只需要进行简单的安装 npm install marked
,然后通过以下代码即可将此内容以 HTML 文本的形式计算出来。
import marked from 'marked';
console.log(marked(the_content_above));
/*
Result:
<h1>Hello Marked</h1><p>My name is Juner.</p>
*/
深入了解 Marked
上文我们通过简单示例讲述了 Marked 的用法,事实上它的内部逻辑远比外在表现复杂得多。我们先要了解它的工作原理才能更好地使用它提供的能力来实现我们的需求。通过研究它的源码,我们可以了解到它渲染 Markdown 语法的过程主要分为 6 步。
- 接收使用者的 Markdown 输入;
- 词法分析器开始分析输入内容,将其解析为一个个独立的词法标记,并将这些词法标记组合为具有嵌套关系的树形结构;
- 每一个词法标记的产生都来自于对应词法的正则表达式匹配结果,结果中捕获的字符串就成为了这个词法标记的对应内容(属性);
- 在最终的词法树导出之前,还需遍历一遍所有的词法标记对其进行个性化的特殊处理,比如让所有标题的级别降一级;
- 渲染器接收词法分析器导出的词法树,遍历词法树,并为每个标记分配对应的渲染函数,最终将这些渲染函数的导出结果组合成最终的渲染结果;
- 每一个渲染函数都会接收一个词法标记并通过其保存的正则匹配内容生成对应的局部 HTML 文本。
我简单画了一个流程图来描述上述过程:
以下是这个过程最外层的伪代码:
function marked(src) {
const tokens = lex(src);
const html = parse(tokens);
return html;
}
let tokens, inlineQueue;
function lex(src) {
tokens = [];
inlineQueue = [];
src = src
.replace(/\r\n|\r/g, '\n')
.replace(/\t/g, ' ');
blockTokens(src, tokens);
let next;
while (next = inlineQueue.shift()) {
inlineTokens(next.src, next.tokens);
}
return tokens;
}
词法分析的过程
词法分析是我们要重点讲的部分。Marked 的词法分析是通过正则表达式实现的,它会对当前内容使用各个不同语法的表达式进行匹配,在其中一个表达式匹配成功后就将当前匹配到文本内容交给那个表达式所属的词法分析器进行进一步分析,然后在当前内容的基础上截断刚刚匹配到的内容,并进行下一次同样操作的循环,直到所有内容都被消耗完毕。
Markdown 语法分为块级语法和行内语法,比如大标题(# xxx
)就是块级语法,加粗(**xxx**
)就是行内语法。两者解析过程都是大同小异的,我们来看一下块级语法解析过程的伪代码。
let top = false;
function blockTokens(src, tokens = []) {
let token, lastToken, cutSrc, lastParagraphClipped;
while (src) {
// ...
cutSrc = src;
if (top && (token = paragraph(cutSrc))) {
lastToken = tokens[tokens.length - 1];
if (lastParagraphClipped && lastToken.type === 'paragraph') {
lastToken.raw += '\n' + token.raw;
lastToken.text += '\n' + token.text;
inlineQueue.pop();
inlineQueue[inlineQueue.length - 1].src = lastToken.text;
} else {
tokens.push(token);
}
lastParagraphClipped = (cutSrc.length !== src.length);
src = src.substring(token.raw.length);
continue;
}
// ...
if (src) { /* 没有任何语法匹配到内容时做一个报错进行中断循环的保护 */ }
}
top = true;
return tokens;
}
正如我们所说的,这段伪代码中先进行了词法匹配得到了一个词法标记(token = paragraph(cutSrc)
),然后对总内容进行了一个消耗(src = src.substring(token.raw.length);
)。我们省略了其他种类词法的分析过程,这里展示的是普通段落词法的匹配过程,它主要处理了连续换行内容都同属于一个段落的情况,因此它还取出了上一个词法 lastToken
来完成这种情况的适配。
词法标记构建过程
这段伪代码中没有我们所说的正则表达式匹配的过程,那这个过程在哪里呢?没错,就在对应词法匹配的过程中(token = paragraph(cutSrc)
),下面我们就来看一下「引用」这个块级词法的解析过程。
function blockquote(src) {
const cap = rules.block.blockquote.exec(src);
if (cap) {
const text = cap[0].replace(/^ *> ?/gm, '');
return {
type: 'blockquote',
raw: cap[0],
tokens: blockTokens(text, []),
text,
};
}
}
这段伪代码看上去非常简单明了,无非就是用正则表达式 rules.block.blockquote
对内容进行匹配,然后对匹配结果进行去格式符处理,最后构建出一个词法标记对象。其中有一行代码尤为重要,blockTokens(text, [])
,它的作用是递归地解析它匹配到的内容,只有这样才能最终形成一个词法树状结构。
过程确实不难,真正难的是正则表达式的编写。翻找 Marked 正则表达式定义位置,我们找到了「引用」的正则匹配表达式
正则匹配原理
这个表达式的可视化结果非常明了,无非就是匹配了以 >
开头的内容。但是我们直接通过这个表达式是无法直接匹配到引用内容的,因为表达式中含有一个强匹配字符串 paragraph
,这个字符串不是用作匹配的,而是用作计算最终正则表达式的标识,它会被真正检测段落内容的正则给替换,下面就是替换 paragraph
后的最终表达式。
一下子就变得非常复杂了,因为引用中可以书写任意其他格式的 Markdown 内容,简单地说,引用中的内容单独摘出来依旧是一篇格式完整的 Markdown 文章,因此它需要匹配到所有情况。这里暂且不进行深入理解,再简单看一下「标题」的正则匹配表达式。
相较于引用的表达式,标题的表达式就友好得多。
行内词法分析
行内词法分析过程和块级大同小异,这里只拓展一下视野,我们看一下其他部分行内格式的 Markdown 匹配表达式。
加粗/斜体
链接/图片
使用 Marked 扩展我们的语法
Marked 的设计始终遵循「单一职责与开放封闭」原则,得益与它的设计理念,我们可以通过它提供的 marked.use({...})
方法自由地扩展我们的词法分析器、渲染器等。
明确需求
首先我们要明确需求,到底需要扩展怎样的语法。这里就拿我们遇到的产品需求进行扩展,我们要在文章中加入酒名片以及视频。这两种元素都属于块级元素,我们直接定义好我们的 Markdown 语法形式。
@alcohol {"id":12345,"name":"罗曼湖","cover":"http://example.com/example.jpg","score":10}
v[](http://example.com/example.mp4)
我们以 @alcohol
开头并紧跟 JSON 的内容作为酒名片,以 v
开头的链接作为视频。接下来我们就开始按照 Marked 的规则构建我们自己的词法分析器和渲染器。
编写正则检测表达式
我们定制的这两种语法的结构都非常简单,这里就不对正则表达式的书写进行详细说明,我们直接得出结论。
酒名片匹配表达式
const pattern = /^@alcohol (.+)(?:\n|$)/;
视频匹配表达式
const pattern = /^v\[]\((https?:\/\/.+\.mp4)\)(?:\n|$)/;
编写词法分析函数
Marked 要求我们在词法分析函数中最终返回至少包含有 type
以及 raw
两个属性的对象。
type
属性直接标明了该词法标记所属类型,将会依据这个字段来分配同类型的渲染函数;raw
属性用来存储我们匹配到的所有字符,以便词法调度中心能根据这个值的长度对总内容做一个截断操作。
除这两者之外,我们可以尽情地存放我们在渲染中需要的数据。
酒名片分析器
function tokenizer(src) {
const rule = /^@alcohol (.+)(?:\n|$)/;
const match = rule.exec(src);
if (match) {
const json = match[1].trim();
try {
return {
type: 'alcohol',
raw: match[0],
text: json,
data: JSON.parse(json),
};
} catch (err) {
console.error(err);
return null;
}
}
return null;
}
视频分析器
function tokenizer(src) {
const rule = /^v\[]\((https?:\/\/.+\.mp4)\)(?:\n|$)/;
const match = rule.exec(src);
if (match) {
return {
type: 'video',
raw: match[0],
text: match[1],
link: match[1],
};
}
return null;
}
编写渲染函数
渲染函数相较词法分析器函数比较简单,Marked 调度中心会自动调用对应类型的渲染函数并将词法标记传入。
酒卡片渲染器
function renderer(token) {
const {
id, name, cover, score,
} = token.data;
return `<section id="alcohol-${id}">`
+ `<img src="${cover}" alt>`
+ `<div class="message"><p class="name">${name}</p><p class="score">${score}</p></div>`
+ '</section>';
}
视频渲染器
function renderer(token) {
return '<video controls>'
+ `<source src="${token.link}" type="video/mp4">`
+ '<p>本视频暂不支持播放</p>'
+ '</video>';
}
完整的代码
有了以上词法分析器和渲染器这两个重中之重的工具,我们就可以将完整的规则传递给 Marked。
import marked from 'marked';
const alcoholRule = {
name: 'alcohol',
level: 'block',
// 起始位置用于向 Marked 标记本规则是从何处开始的
start(src) {
return src.match(/@alcohol {[^\n]*}/)?.index;
},
tokenizer(src) {
const rule = /^@alcohol (.+)(?:\n|$)/;
const match = rule.exec(src);
if (match) {
const json = match[1].trim();
try {
return {
type: 'alcohol',
raw: match[0],
text: json,
data: JSON.parse(json),
};
} catch (err) {
console.error(err);
return null;
}
}
return null;
},
renderer(token) {
const {
id, name, cover, score,
} = token.data;
return `<section id="alcohol-${id}">`
+ `<img src="${cover}" alt>`
+ `<div class="message"><p class="name">${name}</p><p class="score">${score}</p></div>`
+ '</section>';
},
};
const videoRule = {
name: 'video',
level: 'block',
start(src) {
return src.match(/v\[]\(https?:\/\//)?.index;
},
tokenizer(src) {
const rule = /^v\[]\((https?:\/\/.+\.mp4)\)(?:\n|$)/;
const match = rule.exec(src);
if (match) {
return {
type: 'video',
raw: match[0],
text: match[1],
link: match[1],
};
}
return null;
},
renderer(token) {
return '<video controls>'
+ `<source src="${token.link}" type="video/mp4">`
+ '<p>本视频暂不支持播放</p>'
+ '</video>';
},
};
marked.use({
extensions: [alcoholRule, videoRule],
});
console.log(marked.parse(
'@alcohol {"id":12345,"name":"罗曼湖","cover":"http://example.com/example.jpg","score":10}\n'
+ '\n'
+ 'v[](http://example.com/example.mp4)',
));
运行以上代码我们将会得到如下结果:
<section id="alcohol-12345">
<img src="http://example.com/example.jpg" alt>
<div class="message">
<p class="name">罗曼湖</p>
<p class="score">10</p>
</div>
</section>
<video controls>
<source src="http://example.com/example.mp4" type="video/mp4">
<p>本视频暂不支持播放</p>
</video>
小结
至此我们已经完成了产品爸爸的需求。当然文中的示例都比较简单,还有比较复杂的表格匹配甚至于数学表达式、流程图的匹配,这些匹配渲染都需要极高的正则造诣,恕鄙人才疏学浅,无法再为各位深入讲解这类元素的匹配过程了。
如果大家感兴趣,可以自行阅读源码,再结合官方文档的 Extensibility( marked.js.org/using_pro )章节,构建属于你的 Markdown 语法。
更多精彩请关注我们的公众号“ 百瓶技术 ”,有不定期福利呦!