少女祈祷中...

由于Hexo中使用了bluebird这个Promise库,会导致代码较难理解
本文会省略一些和issue无关的代码

最近看到了Hexo的issue #4976,其中提到了大文件的CSS在生成的过程中可能会丢失部分代码。本人感觉这个问题非常有意思,于是自己也尝试了一下大文件的CSS,没想到也成功复现了这个问题。
先说结论:问题应该出现在post.js中的escapeAllSwigTags()函数。由于压缩过的CSS中可能出现诸如{#main 这样的语句,而这样的语句会在这个函数中被当成swig模板进行处理,导致了代码的丢失。
解决方法:在_config.ymlskip_render中添加CSS的相对路径
以下为排查的过程和部分源码的分析:

hexo-cli

hexo中所输入的命令实际运行的是hexo/bin/hexo文件:

1
2
#!/usr/bin/env node
require('hexo-cli')();

hexo文件中直接导入hexo-cli模块,查看hexo-cli入口点:

1
2
3
{
"main": "lib/hexo"
}

即入口点为hexo/node_modules/hexo-cli/lib/hexo.js,其模块导出:

1
module.exports = entry;

查看entry()函数:
其输入两个参数cwdargs。在该函数中调用了loadModule()函数,并将其返回结果赋值给hexo,接着调用其init()函数

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
function entry(cwd = process.cwd(), args) {
//...
//此时的hexo变量不是真正的Hexo对象
let hexo = new Context(cwd, args);

//...

return findPkg(cwd, args).then(path => {
if (!path) return;

hexo.base_dir = path;

return loadModule(path, args).catch(err => {
//...
});
}).then(mod => {
//将loadModule返回的Hexo对象赋值给hexo
if (mod) hexo = mod;
//引入console模块,其中包含部分命令如init,help和version
require('./console')(hexo);
//调用其init()函数
return hexo.init();
}).then(() => {
//...
}).catch(handleError);
}

查看loadModule()函数,在该函数中创建Hexo对象并返回:

1
2
3
4
5
6
7
8
function loadModule(path, args) {
return Promise.try(() => {
const modulePath = resolve.sync('hexo', { basedir: path });
const Hexo = require(modulePath);
//创建Hexo对象,args代表命令参数
return new Hexo(path, args);
});
}

总结:hexo-cli中创建Hexo对象,并调用其init()函数

hexo初始化

查看hexo入口点:

1
2
3
{
"main": "lib/hexo"
}

即入口点为hexo/lib/hexo/index.js,其模块导出:

1
module.exports = Hexo;

先查看Hexo类的构造函数,在该函数中主要为属性赋值,初始化配置文件;同时初始化数据库,绑定查询方法

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
constructor(base = process.cwd(), args = {}) {
super();
//初始化各种路径变量
this.base_dir = base + sep;
this.public_dir = join(base, 'public') + sep;
this.source_dir = join(base, 'source') + sep;
this.plugin_dir = join(base, 'node_modules') + sep;
this.script_dir = join(base, 'scripts') + sep;
this.scaffold_dir = join(base, 'scaffolds') + sep;
this.theme_dir = join(base, 'themes', defaultConfig.theme) + sep;
this.theme_script_dir = join(this.theme_dir, 'scripts') + sep;
//初始化环境变量
this.env = {
args,
debug: Boolean(args.debug),
safe: Boolean(args.safe),
silent: Boolean(args.silent),
env: process.env.NODE_ENV || 'development',
version,
cmd: args._ ? args._[0] : '',
init: false
};
//初始化各类extend模块
this.extend = {
console: new Console(),
deployer: new Deployer(),
filter: new Filter(),
generator: new Generator(),
helper: new Helper(),
injector: new Injector(),
migrator: new Migrator(),
processor: new Processor(),
renderer: new Renderer(),
tag: new Tag()
};
//其余的初始化
this.config = { ...defaultConfig };

this.log = logger(this.env);

this.render = new Render(this);

this.route = new Router();

this.post = new Post(this);

this.scaffold = new Scaffold(this);

this._dbLoaded = false;

this._isGenerating = false;

// If `output` is provided, use that as the
// root for saving the db. Otherwise default to `base`.
const dbPath = args.output || base;
//...
//初始化数据库,用于临时存储需要生成和处理的原始文件
this.database = new Database({
version: dbVersion,
path: join(dbPath, 'db.json')
});
//初始化配置文件
const mcp = multiConfigPath(this);

this.config_path = args.config ? mcp(base, args.config, args.output)
: join(base, '_config.yml');
//注册数据库中的模型(相当于表),模型中具体的schema定义可以查阅hexo/lib/models中对应的模块
registerModels(this);

this.source = new Source(this);
this.theme = new Theme(this);
this.locals = new Locals(this);
//绑定local的查询方法
this._bindLocals();
}

由于在hexo-cli调用了Hexo类中的init()函数,查看该函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
init() {
//...

//加载外部plugins
require('../plugins/console')(this); //控制台插件,用于处理输入的指令
require('../plugins/filter')(this); //过滤器插件
require('../plugins/generator')(this);//生成器插件,用于生成转换后的文件
require('../plugins/helper')(this);
require('../plugins/injector')(this);
require('../plugins/processor')(this);//处理器插件,用于文件生成前的预处理
require('../plugins/renderer')(this);
require('../plugins/tag')(this);

//加载配置
return Promise.each([
'update_package', // Update package.json
'load_config', // Load config
'load_theme_config', // Load alternate theme config
'load_plugins' // Load external plugins & scripts
], name => require(`./${name}`)(this)).then(() => this.execFilter('after_init', null, { context: this })).then(() => {
// Ready to go!
this.emit('ready');
});
}

至此,Hexo初始化完成,可以开始执行用户输入的指令

generate

hexo/lib/plugins/console用于处理用户输入的指令
hexo/lib/plugins/console/index.js是该模块的入口,该模块用于向对应的extend中注册模块,以下以generate命令为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module.exports = function(ctx) {
const { console } = ctx.extend;
//...
//注册generate相关处理模块,require('./generate')
console.register('generate', 'Generate static files.', {
options: [
{name: '-d, --deploy', desc: 'Deploy after generated'},
{name: '-f, --force', desc: 'Force regenerate'},
{name: '-w, --watch', desc: 'Watch file changes'},
{name: '-b, --bail', desc: 'Raise an error if any unhandled exception is thrown during generation'},
{name: '-c, --concurrency', desc: 'Maximum number of files to be generated in parallel. Default is infinity'}
]
}, require('./generate'));

//...
};

查看同目录下的generate.js模块:
其创建了Generater对象,并调用了this.load()函数,由于this就是Hexo对象,所以相当于调用了Hexo对象中的load()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function generateConsole(args = {}) {
const generator = new Generater(this, args);
//如果指令中存在 -w 或 --watch 则会执行以下代码
if (generator.watch) {
return generator.execWatch();
}
//先调用load()函数加载需要生成的文件,之后才进行生成操作
return this.load().then(() => generator.firstGenerate()).then(() => {
//如果指令中存在 -d 或 --deploy,则会执行以下代码
if (generator.deploy) {
return generator.execDeploy();
}
});
}

module.exports = generateConsole;

查看Hexo类中的load()函数:
该函数首先调用load_database.js中的loadDatabase模块,先检查是否在根目录下存在db.json数据库文件,如果有则进行读取,否则直接返回
由于hexo将需要处理的文件分成了source(\source目录下的文件)和theme(\themes目录下的文件),所以分别需要对这两个部分执行process()函数进行预处理
在异步调用结束后,需要生成的文件已经被存入了hexo对象中的database属性中,等待被生成。此时执行mergeCtxThemeConfig()函数进行配置的融合,并调用_generate()函数用于执行生成前和生成后的过滤器(filter)
由于CSS文件位于\source目录下,所以CSS文件会在this.source.process()中被处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
load(callback) {
//检查是否在根目录下存在`db.json`数据库文件,如果有则进行读取,否则直接返回
return loadDatabase(this).then(() => {
this.log.info('Start processing');
//进行预处理,将文件读入数据库中
return Promise.all([
this.source.process(),
this.theme.process()
]);
}).then(() => {
//融合配置文件
mergeCtxThemeConfig(this);
//执行生成前和生成后的过滤器(filter)
return this._generate({ cache: false });
}).asCallback(callback);
}

由于issue中所提到的CSS文件属于soruce,所以只需要研究this.source.process()

processor

查看hexo/lib/box/index.js中的process()函数:
重点在于最后的return语句,通过_readDir()函数读取文件到数据库中,再使用过滤器处理被删除的文件。而issue中提到的问题正是在将文件读取到数据库中发生的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
process(callback) {
const { base, Cache, context: ctx } = this;

return stat(base).then(stats => {
//...

// Handle deleted files
return this._readDir(base)
.then(files => cacheFiles.filter(path => !files.includes(path)))
.map(path => this._processFile(File.TYPE_DELETE, path));
}).catch(err => {
//...
}).asCallback(callback);
}

查看同文件下_readDir()函数:
函数比较简单,即递归读取特定目录下所有文件,检查其状态并使用_processFile()函数进行处理
读取文件本身不存在问题,问题出在对读取出来数据的处理上

1
2
3
4
5
6
7
_readDir(base, prefix = '') {
const results = [];
return readDirWalker(base, results, this.ignore, prefix)
.return(results)
.map(path => this._checkFileStatus(path))
.map(file => this._processFile(file.type, file.path).return(file.path));
}

查看同文件下_processFile函数:
bluebird的使用使得代码较难理解,大意就是对于每个path,判断其是否匹配processor中的pattern。如果匹配,则执行processor中的process()函数,并将结果返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
_processFile(type, path) {

//...
//对this.processor中的每个processor都进行如下操作
return Promise.reduce(this.processors, (count, processor) => {
//判断path是否匹配processor中的pattern
const params = processor.pattern.match(path);
//如果不匹配,则直接返回
if (!params) return count;
const file = new File({
source: join(base, path),
path,
params,
type
});
//否则,执行processor中的process方法,并将结果返回
return Reflect.apply(Promise.method(processor.process), ctx, [file])
}, 0).catch(err => {
ctx.log.error({ err }, 'Process failed: %s', magenta(path));
}).finally(() => {
this._processingFiles[path] = false;
}).thenReturn(path);
}

注意,这里的this是hexo对象中的source而非theme,所以查看hexo/lib/hexo/source.js

1
2
3
4
5
6
7
class Source extends Box {
constructor(ctx) {
super(ctx, ctx.source_dir);

this.processors = ctx.extend.processor.list();
}
}

继续查看hexo/lib/extend/processor.js
可以知道最终processors中的处理器就是那些初始化时被注册的处理器(和console一样)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Processor {
constructor() {
this.store = [];
}

list() {
return this.store;
}

register(pattern, fn) {
//...
this.store.push({
pattern: new Pattern(pattern),
process: fn
});
}
}

继续查看hexo/lib/plugins/processor/index.js
可以知道assetdatapost三个处理器被成功注册,而CSS文件是归属于asset进行处理的

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = ctx => {
const { processor } = ctx.extend;

function register(name) {
const obj = require(`./${name}`)(ctx);
processor.register(obj.pattern, obj.process);
}

register('asset');
register('data');
register('post');
};

继续查看hexo/lib/plugins/processor/asset.js
CSS文件的renderabletrue,所以会进入processPage()函数中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
module.exports = ctx => {
return {
pattern: new Pattern(path => {
if (isExcludedFile(path, ctx.config)) return;

return {
//如果这里_config.yml中设置了skip_render,则这里的renderable会变为false,也就不会参与之后的转义了
renderable: ctx.render.isRenderable(path) && !isMatch(path, ctx.config.skip_render)
};
}),

process: function assetProcessor(file) {
if (file.params.renderable) {
return processPage(ctx, file);
}

return processAsset(ctx, file);
}
};
};

继续查看processPage()函数:
在该函数中,主要是读取对应文件,进行一定的处理后将结果存入数据库的Page模型中

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
function processPage(ctx, file) {
const Page = ctx.model('Page');
const { path } = file;
const doc = Page.findOne({source: path});
const { config } = ctx;
const { timezone: timezoneCfg } = config;

//...

return Promise.all([
file.stat(),
//读取文件
file.read()
]).spread((stats, content) => {
const data = yfm(content);
const output = ctx.render.getOutput(path);

data.source = path;
//raw是读取的原始数据
data.raw = content;

data.date = toDate(data.date);
//一系列处理
//...
//存入数据库中
return Page.insert(data);
});
}

filter

process()函数已经完成,此时回到load()函数中:
开始执行_generate()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
load(callback) {
//检查是否在根目录下存在`db.json`数据库文件,如果有则进行读取,否则直接返回
return loadDatabase(this).then(() => {
this.log.info('Start processing');
//进行预处理,将文件读入数据库中
return Promise.all([
this.source.process(),
this.theme.process()
]);
}).then(() => {
//融合配置文件
mergeCtxThemeConfig(this);
//执行生成前和生成后的过滤器(filter)
return this._generate({ cache: false });
}).asCallback(callback);
}

查看同目录下的_generate()函数:
基本上就是先运行before_generate过滤器,接着运行_runGenerators调用生成器进行生成,最后运行after_generate过滤器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
_generate(options = {}) {
//...

this.emit('generateBefore');

//运行before_generate过滤器
return this.execFilter('before_generate', this.locals.get('data'), { context: this })
//运行_runGenerators调用生成器进行生成
.then(() => this._routerReflesh(this._runGenerators(), useCache)).then(() => {
this.emit('generateAfter');

//运行after_generate过滤器
return this.execFilter('after_generate', null, { context: this });
}).finally(() => {
this._isGenerating = false;
});
}

问题出现在before_generate过滤器之中,查看hexo/lib/plugins/filter/before_generate/render_post.js
在该过滤器中,对于Post模型和Page模型分别调用render()函数对post进行处理(如转义)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function renderPostFilter(data) {
const renderPosts = model => {
//获得所有content为空的post
const posts = model.toArray().filter(post => post.content == null);

return Promise.map(posts, post => {
//先赋值为_content
post.content = post._content;
post.site = {data};
//调用render()函数对post进行处理(如转义)
return this.post.render(post.full_source, post).then(() =>
//保存回数据库
post.save());
});
};

return Promise.all([
renderPosts(this.model('Post')),
renderPosts(this.model('Page'))
]);
}

查看hexo/lib/hexo/post.js中的render()函数:
在该函数中,首先运行before_post_render过滤器,接着在对文件进行转义操作后使用渲染器对markdown等进行渲染,最后运行after_post_render过滤器
问题就出在escapeAllSwigTags()函数中。由于压缩过的CSS中可能出现诸如{#main这样的语句,而这样的语句会在这个函数中被当成swig模板进行处理,导致文件丢失部分代码

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
30
31
32
render(source, data = {}, callback) {
const ctx = this.context;
const { config } = ctx;
const { tag } = ctx.extend;
const ext = data.engine || (source ? extname(source) : '');

let promise;

//...

return promise.then(content => {
data.content = content;
//运行before_post_render过滤器
return ctx.execFilter('before_post_render', data, { context: ctx });
}).then(() => {
data.content = cacheObj.escapeCodeBlocks(data.content);
// Escape all Nunjucks/Swig tags
if (disableNunjucks === false) {
//问题出在这句话
//CSS不需要转义swig模板!!!
data.content = cacheObj.escapeAllSwigTags(data.content);
}

// 使用渲染器对markdown等进行渲染
//...
}).then(content => {
data.content = cacheObj.restoreCodeBlocks(content);

//运行after_post_render过滤器
return ctx.execFilter('after_post_render', data, { context: ctx });
}).asCallback(callback);
}