由于 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 文件:

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

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

{
  "main": "lib/hexo"
}

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

module.exports = entry;

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

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 对象并返回:

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 入口点:

{
  "main": "lib/hexo"
}

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

module.exports = Hexo;

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

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() 函数,查看该函数:

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 命令为例:

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() 函数

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() 中被处理

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 中提到的问题正是在将文件读取到数据库中发生的

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() 函数进行处理
读取文件本身不存在问题,问题出在对读取出来数据的处理上

_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() 函数,并将结果返回

_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

class Source extends Box {
  constructor(ctx) {
    super(ctx, ctx.source_dir);
    this.processors = ctx.extend.processor.list();
  }
}

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

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 进行处理的

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() 函数中

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 模型中

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() 函数

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 过滤器

_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 进行处理 (如转义)

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 模板进行处理,导致文件丢失部分代码

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);
}