Hexo的标签渲染系统的一点分析

1 问题引入

Hexo的标签(Tag)系统一直有一点「格格不入」的感觉。注意这里的标签系统是指使用了

1
{% ... %}

标签插件。这里我用的代码块的方式给出标签插件的代码形式,而不是使用的更加简洁的行间代码块,为什么呢?因为直接在Hexo的markdown博文里面写

1
`{% ... %}`

会导致解析失败。这是何等的卧槽。但是其实如果了解解析失败的原因,我们可以用一些复杂的办法来把这个行间代码打出来:{% %}。 具体的写法是

1
`{% raw %}{% %}{% endraw %}`

那么为什么行间公式渲染会出现这个问题,但是块公式渲染又不会出现这个问题呢?另外,为什么使用上面的这个写法就能够让{% %}被正确的渲染呢?要搞清楚这些问题,我们首先要大致梳理一下Hexo的标签系统的实现过程。

2 实现分析

2.1 cacheObj

这里我分析的Hexo版本为3.8.0。尽管不是最新的Hexo,但是目前来看Hexo并没有修改标签插件系统的的实现方法,所以我们这里的分析还是适用的。关键的代码部分位于Hexo项目下的lib/hexo/post.js文件中的函数Post.prototype.render,这个函数负责进行文章内容的渲染。函数里的关键内容如下,注释部分是我的分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Post.prototype.render = function(source, data = {}, callback) {
// 标签插件的渲染系统
const { tag } = ctx.extend;
// ...
const cacheObj = new PostRenderCache()
// ...
// promise为从文件路径读取内容的promise。
return promose.then(content => {
// ...
}).then(() => {
data.content = cacheObj.escapeContent(data.content);
// ...
if (!disableNunjucks) {
data.content = cacheObj.escapeAllSwigTags(data.content);
}
// ...
return ctx.render.render({
text: data.content,
path: source,
engine: data.engine,
toString: true,
onRenderEnd(content) {
// ...
data.content = cacheObj.loadContent(content);
return tag.render(data.content, data)
}
})
})
}

上面的函数中,ctx.render会将我们的markdown内容交给对应的渲染器进行渲染。注意在渲染内容之前,先用cacheObj.escapeContentcacheObj.escapeAllSwigTags进行了处理。而在渲染完成之后的回调中,又用cacheObj.loadContent对渲染结果进行了处理,并再次使用tag.render进行了二次渲染。那么cacheObj具体做了什么呢?我们来看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const _escapeContent = (cache, str) => {
const placeholder = '\uFFFC';
return `<!--${placeholder}${cache.push(str) - 1}-->`;
};

PostRenderCache.prototype.escapeContent = function(str) {
const rEscapeContent = /<escape(?:[^>]*)>([\s\S]*?)<\/escape>/g;
return str.replace(rEscapeContent, (_, content) => _escapeContent(this.cache, content));
};

PostRenderCache.prototype.loadContent = function(str) {
const rPlaceholder = /(?:<|&lt;)!--\uFFFC(\d+)--(?:>|&gt;)/g;
return str.replace(rPlaceholder, (_, index) => this.cache[index]);
};

PostRenderCache.prototype.escapeAllSwigTags = function(str) {
const rSwigVar = /\{\{[\s\S]*?\}\}/g;
const rSwigComment = /\{#[\s\S]*?#\}/g;
const rSwigBlock = /\{%[\s\S]*?%\}/g;
const rSwigFullBlock = /\{% *(.+?)(?: *| +.*)%\}[\s\S]+?\{% *end\1 *%\}/g;

const escape = _str => _escapeContent(this.cache, _str);
return str.replace(rSwigFullBlock, escape)
.replace(rSwigBlock, escape)
.replace(rSwigComment, '')
.replace(rSwigVar, escape);
};

我们注意到在escapeAllSwigTags中,会将待处理文档中的/\{%[\s\S]*?%\}/g这种模式的字符串,也就是形如{% ... %}的内容,会被替换成`<!--${placeholder}${cache.push(str) - 1}-->`的形式,距离而言,

1
这是 {% 一段 %} 文字

在经过 escapeAllSwigTags的处理之后会变成

1
这是 <!--1--> 文字

注意\uFFFC这个Unicode字符是OBJECT REPLACEMENT CHARACTER,很多字体都不支持显示。Unicode对这个字符的定义是used as placeholder in text for an otherwise unspecified object

而被替换的内容则会被cacheObj缓存起来。被替换的内容<!--1-->中的数字会自动随着缓存数量的增长而增长,从而实现了从缓存列表到占位内容之间的映射。被替换的内容在HTML语义上是注释内容,因此一般的markdown渲染器都会无视这些内容。

在完成渲染后,cacheobj.loadContent会把缓存的内容,插入回对应占位符的位置。

cacheObj对于渲染内容的处理,确保了标签插件内容不会被系统默认的markdown渲染器处理。

2.2 tag.render

经过cacheObj处理的文本内容随后交给tag.render来处理。这里tag对象的实现位于文件lib/extend/tag.js。而在运行时中,这里的tag就是我们自己实现标签插件的时候调用的hexo.extend.tag对象。我们实现的标签插件的函数,就被注册到这个对象中。这个对象的关键实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Tag() {
this.env = new nunjucks.Environment(null, {
autoescape: false
});
}

Tag.prototype.register = function(name, fn, option) {
// ...
this.env.addExtension(name, tag);
}

Tag.prototype.render = function(str, options, callback) {
// ...
str = str.replace(/<pre><code.*>[\s\S]*?<\/code><\/pre>/gm, escapeContent);
return Promise.fromCallback(cb => { this.env.renderString(str, options, cb); })
.then(result => result.replace(rPlaceholder, (_, index) => cache[index]));
};

这里我们看到,Tag系统其实是对nunjucks的封装,而对于{% ... %}这类内容的处理,也是交给nunjucks来处理的。在nunjucks中{% ... %}就是对应的自定义标签的使用。

Nunjucks 是 jinja2 的 javascript 的实现,所以如果此文档有什么缺失,你可以直接查看 jinja2 的文档,不过两者之间还存在一些差异。

值得注意一点的是,在渲染过程中tag特别处理了/<pre><code.*>[\s\S]*?<\/code><\/pre>/gm对应的内容,而这部分内容,也恰好是块代码渲染出来的内容。

至此我们可以解释为什么在行内代码渲染会出问题,而块代码会出问题。行内代码的渲染内容是用<code></code>包裹的,之部分内容会直接被nunjucks解析,因此内部的{% ... %}会被当成nunjucks的自定义标签解析,导致识别错误。而块代码会被<pre><code></code></pre>包裹,这部分内容会被tag.render提取出来避免被nunjucks处理。

注意从浏览器中浏览实际渲染出来的内容你可能无法找到<pre><code></code></pre>的渲染结构,这是因为代码高亮模块对这部分内容进行了处理。

2.3 从nunjucks角度解决问题

这里我们可以解释为什么{% raw %}{% %}{% endraw %}可以解决开头的问题了。在nunjucks中,raw标签可以允许输出一些Nunjucks的特殊符号。

上面的第一个行内代码是用{% raw %}{% raw %}{% %}{% endraw %}{% endraw %}才渲染出来。

2.4 小结

从前面的分析来看,Hexo的所谓标签插件系统其实是完全复用的nunjucks这个模板引擎的自定义标签系统,Hexo做的主要工作是使这个标签系统能够同Hexo的各种渲染器协同工作。

3 讨论

  1. 除了{% ... %}这些Nunjucks的特殊符号以外,还有{{ ... }}{# ... #}也会存在类似的问题。
  2. 之前我为hexo-renderer-pandoc提交了一个PR。因为这个渲染器只注册了异步渲染函数,没有注册同步,会导致标签插件的markdown内容无法被渲染。这是当时的Issue
  3. nunjucks渲染问题除了带来一些问题,利用得到还是能够实现一些更加fancy的功能的。