背景
最近在移植WordPress的Sakura主题到Halo上(实际上只是参照样式重写了)。评论这里需要使用marked,Halo官方提供的表情不太适合我,且我早就想扩展一个带表情的marked了。因此正好借着这个机会,扩展一个带表情的marked。
后续也想扩展一下文章页面的marked,只是目前还没有插件。因此暂时先只改评论的即可,到时候通用一套语法即可。
【注:当前文章大部分内容为分析,篇幅较长,如果不想看过程,直接跳转至扩展即可】
本篇文章基于如下内容
marked.js:https://github.com/markedjs/marked
说明文档:https://marked.js.org/#/README.md#README.md
计划
根据我的计划,目前需要实现增加如下的功能
- 扩展bilibili表情
- 扩展文字表情
- 扩展通用的Emoji表情
-
增加通用的动态表情 - 将自定义的marked打包至npm
分析
工欲善其事必先利其器。 扩展marked之前一定要了解清楚,如何扩展?是否拥有不修改源码的扩展方法?如果有如何扩展?如果没有,如何修改源码?抱着这个想法,我阅读了 marked.js 的官方文档以及源码。
分析文档
在marked.js的官方文档中,我找到了 Extending Marked 这章内容。
刚开始,我认为也许通过官方的扩展Marked文档即可实现,但仔细阅读之后,发现并没有那么简单。
使用官方的扩展语法,只能扩展已有的渲染器方法。因为他们需要一个方法,例如 _heading(string src) ,进而通过该方法的返回值,来修改渲染样式。_简而言之,官方的方法只能针对于已有的语法,然后修改其渲染方式,如渲染标题时,我们可以不使用默认的H1、H2,而改用div自定义渲染等等。即官方已经替你解析完毕,你要做的只是按照自己想要的方式去渲染即可
而我们想实现的功能,是新添加一个解析,使用自己的解析语法,因此官方的这种方法不符合我们的要求。
继续翻看文章,在最后发现了如下内容
这里可以看到大致流程,marked 使用 lexer 解析 markdown 文档成一个 tokens,然后使用 tokens 转换成 html。这是最重要的两步,那么问题来了,自定义的markdown语法如何解析成tokens? 又如何从 tokens 渲染成 html? 这里并没有提及,那么剩下的就只能从源码去看了。
分析源码
Lexer.js
根据文档,marked使用Lexer将markdown转换成tokens,则直接查看 Lexer.js,下图是Lexer的方法
其中获取Tokens分为了blockTokens/inlineTokens。根据方法名就能联想到分别是块级和行内的区别。由于我们想要新添加的表情属于行内,因此只需在inlineTokens中添加即可。
继续分析Lexer.js,inlineTokens方法的代码如下所示
inlineTokens(src, tokens = [], inLink = false, inRawBlock = false) {
let token;
while (src) {
// escape
if (token = this.tokenizer.escape(src)) {
src = src.substring(token.raw.length);
tokens.push(token);
continue;
}
......
}
return tokens;
src即为当前需要解析的字符串,然后当前方法循环解析字符串,并将字符串转换成token,之后保存在tokens中。
很容易可以看出,当前方法使用了一个tokenizer对象将字符串解析成Token对象。该对象存在于Tokenizer.js中
Tokenizer.js
直接查看Tokenizer.js,找到如下代码
......
escape(src) {
const cap = this.rules.inline.escape.exec(src);
if (cap) {
return {
type: 'escape',
raw: cap[0],
text: escape(cap[1])
};
}
}
......
在这个方法中,很明显使用了 exec() 方法,该方法会检索字符串中的正则表达式的匹配,返回一个数组。如果未能找到,则返回null。
所以此方法调用了rules对象,将字符串进行解析,然后返回一个数组。那么该对象内存的则应该是正则表达式。该对象存在于rules.js中,我们接着看rules.js。
rules.js
很明显,这里即为保存各种正则表达式的地方。
到这一步位置,将字符串转换为Tokens应该就很明确了。
那么,接下来的重点是,如果将Tokens渲染成html。前面也都没有看到有如何渲染的方法,那么,现在就需要我们找到调用Tokens的地方,根据代码,得到在marked.js中调用了Lexer.lex() 方法来获取Tokens。
marked.js
根据源码可以知道,在Parser.parse()中调用了tokens。
Parser.js
parseInline(tokens, renderer) {
renderer = renderer || this.renderer;
let out = '',
i,
token;
const l = tokens.length;
for (i = 0; i < l; i++) {
token = tokens[i];
switch (token.type) {
......
case 'escape': {
out += renderer.text(token.text);
break;
}
}
}
return out;
}
parseInline即为行内的解析器。它按顺序循环Tokens,取出Token中保存的数据,而后调用renderer对象将token渲染成字符串并拼接起来。
renderer对象在Renderer.js中。
Renderer.js
......
heading(text, level, raw, slugger) {
if (this.options.headerIds) {
return '<h'
+ level
+ ' id="'
+ this.options.headerPrefix
+ slugger.slug(raw)
+ '">'
+ text
+ '</h'
+ level
+ '>\n';
}
// ignore IDs
return '<h' + level + '>' + text + '</h' + level + '>\n';
}
......
很容易可以看出来,Renderer就是渲染字符串的地方。使用Parse中解析的值然后渲染成字符串并返回。
分析到这一步,整个思路就非常清楚了。
分析总结
经过上面的分析,可以得出marked的渲染步骤,共有如下几个步骤
- 编写正则表达式,用于将字符串解析成数组
// rules.js
escape: /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,
- 使用正则表达式解析目标字符串,并转化成token
// Tokenizer.js
escape(src) {
const cap = this.rules.inline.escape.exec(src);
if (cap) {
return {
type: 'escape',
raw: cap[0],
text: escape(cap[1])
};
}
}
- 循环解析字符串,将其转换成tokens
// Lexer.js
token = this.tokenizer.escape(src)
// 添加至tokens中
tokens.push(token);
- 将tokens按照特定的格式,使用渲染器进行渲染
// Parser.js
out += renderer.text(token.text);
- 编写某个格式的HTML渲染
// Renderer.js
br() {
return this.options.xhtml ? '<br/>' : '<br>';
}
根据以上思路,就可以立马开工添加表情了。甚至以后如果有其他东西也很方便进行扩展。
扩展
由于我们添加的表情属于行内元素,因此均只考虑行内代码。
bilibiliEmoji对应的markdown语法为: f(x)=∫(xxx)sec²xdx
textEmoji对应的markdown语法为:(⌒▽⌒)
codeEmoji对应的markdown语法为: :xxx:
其中xxx为表情的名字
编写正则
找到rules.js文件,在行内元素的对象中添加三条解析语句
// rules.js
// 正则表达式
const inline = {
bilibiliEmoji: /^f\(x\)=∫\(([^A-Z]\w+?)\)sec²xdx/,
textEmoji: /^`([^a-zA-Z]+?)`/,
codeEmoji: /^:([^A-Z]\w+?):/
......
}
另外,还要确保不能将:,`以及f开头的字符串识别为文本,因此还需要改动一下text的正则表达式
const inline = {
// 增加了!\[`:f*]
text: /^(`+|[^`])(?:[\s\S]*?(?:(?=[\\<!\[`:f*]|\b_|$)|[^ ](?= {2,}\n))|(?= {2,}\n))/
}
// 如果开启了gfm,则还需要改动这个!\[`:f*]
inline.gfm = merge({}, inline.normal, {
text: /^(`+|[^`])(?:[\s\S]*?(?:(?=[\\<!\[`:f*~]|\b_|https?:\/\/|ftp:\/\/|www\.|$)|[^ ](?= {2,}\n)|[^a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-](?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@))|(?= {2,}\n|[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@))/
});
转换字符串为Token
找到Tokenizer.js,在构造函数下新增三条转换语句
// Tokenizer.js
bilibiliEmoji(src) {
const cap = this.rules.inline.bilibiliEmoji.exec(src);
if (cap) {
if (cap[0].length > 1) {
return {
type: 'bilibiliEmoji',
raw: cap[0],
text: cap[1]
};
}
}
}
textEmoji(src) {
const cap = this.rules.inline.textEmoji.exec(src);
if (cap) {
if (cap[0].length > 1) {
return {
type: 'textEmoji',
raw: cap[0],
text: cap[1]
};
}
}
}
codeEmoji(src) {
const cap = this.rules.inline.codeEmoji.exec(src);
if (cap) {
if (cap[0].length > 1) {
return {
type: 'codeEmoji',
raw: cap[0],
text: cap[1]
};
}
}
}
将表情token纳入tokens中
在Lexer.js的inlineTokens中,判断字符串的类别并将token添加到tokens中去
// Lexer.js
inlineTokens(src, tokens = [], inLink = false, inRawBlock = false) {
let token;
while (src) {
// bilibili表情 f(x)=∫(xxx)sec²xdx
if (token = this.tokenizer.bilibiliEmoji(src)) {
src = src.substring(token.raw.length);
if (token.type) {
tokens.push(token);
}
continue;
}
// 文字表情
if (token = this.tokenizer.textEmoji(src)) {
src = src.substring(token.raw.length);
if (token.type) {
tokens.push(token);
}
continue;
}
// 帖吧表情/BBcodeEmoji
if (token = this.tokenizer.codeEmoji(src)) {
src = src.substring(token.raw.length);
if (token.type) {
tokens.push(token);
}
continue;
}
......
}
}
使用tokens进行解析
在Parser.js中,循环tokens,并对每个token按照类型进行解析
之后将获取到的html片段拼接
// Parser.js
parseInline(tokens, renderer) {
renderer = renderer || this.renderer;
let out = '',
i,
token;
const l = tokens.length;
for (i = 0; i < l; i++) {
token = tokens[i];
switch (token.type) {
case 'bilibiliEmoji': {
out += renderer.bilibiliEmoji(token.text);
break;
}
case 'textEmoji': {
out += renderer.textEmoji(token.text);
break;
}
case 'codeEmoji': {
out += renderer.codeEmoji(token.text);
break;
}
......
}
}
}
生成HTML片段
最后根据Parser.js中的解析,调用Renderer.js中renderer对象的方法渲染html片段
// Renderer.js
......
bilibiliEmoji(text) {
let href = text + '.png';
href = cleanUrl(this.options.sanitize, this.options.bilibiliEmojiUrl, href);
return '<span class="emotion-inline emotion-item">'
+ '<img src="'
+ href
+ '" class="img"></span>';
}
textEmoji(text) {
return text;
}
codeEmoji(text) {
let href = 'icon_' + text + '.gif';
href = cleanUrl(this.options.sanitize, this.options.codeEmojiEmojiUrl, href);
return '<img src="'
+ href
+ '" alt=":'
+ text
+ ':" class="smilies">';
}
.....
增加默认配置
在默认的配置文件(defaults.js)中,新增表情的地址,这样可以保证之后可以随意切换表情资源所在的地址
// defaults.js
function getDefaults() {
return {
baseUrl: null,
breaks: false,
gfm: true,
headerIds: true,
headerPrefix: '',
highlight: null,
langPrefix: 'language-',
mangle: true,
pedantic: false,
renderer: null,
sanitize: false,
sanitizer: null,
silent: false,
smartLists: false,
smartypants: false,
tokenizer: null,
walkTokens: null,
xhtml: false,
// 新增的表情地址
bilibiliEmojiUrl: '****',
codeEmojiEmojiUrl: '****'
};
}
至此,解析已经完成。然后就可以对源码进行打包了。
打包
先修改原有的package.json 中的配置为自己的,主要是name,desc,author,version
也可以不修改,看个人。然后执行如下命令
// 安装依赖
npm install
// 执行代码规范性检查(可选)
npm run test:lint
// 打包
npm run build
执行之后,生成的marked.min.js就可以直接使用了。
当然了,我这里需要再发布到npm上,那就需要再执行下面的语句,将代码发布
npm publish
至此,扩展就已经全部完成!