lib/hexo/index.ts

Summary

Maintainability
D
2 days
Test Coverage
import Promise from 'bluebird';
import { sep, join, dirname } from 'path';
import tildify from 'tildify';
import Database from 'warehouse';
import { magenta, underline } from 'picocolors';
import { EventEmitter } from 'events';
import { readFile } from 'hexo-fs';
import Module from 'module';
import { runInThisContext } from 'vm';
const { version } = require('../../package.json');
import logger from 'hexo-log';

import {
  Console,
  Deployer,
  Filter,
  Generator,
  Helper,
  Highlight,
  Injector,
  Migrator,
  Processor,
  Renderer,
  Tag
} from '../extend';

import Render from './render';
import registerModels from './register_models';
import Post from './post';
import Scaffold from './scaffold';
import Source from './source';
import Router from './router';
import Theme from '../theme';
import Locals from './locals';
import defaultConfig from './default_config';
import loadDatabase from './load_database';
import multiConfigPath from './multi_config_path';
import { deepMerge, full_url_for } from 'hexo-util';
import type Box from '../box';
import type { AssetGenerator, LocalsType, NodeJSLikeCallback, NormalPageGenerator, NormalPostGenerator, PageGenerator, PostGenerator, SiteLocals } from '../types';
import type { AddSchemaTypeOptions } from 'warehouse/dist/types';
import type Schema from 'warehouse/dist/schema';

let resolveSync; // = require('resolve');

const libDir = dirname(__dirname);
const dbVersion = 1;

const stopWatcher = (box: Box) => { if (box.isWatching()) box.unwatch(); };

const routeCache = new WeakMap();

const castArray = (obj: any) => { return Array.isArray(obj) ? obj : [obj]; };

// eslint-disable-next-line no-use-before-define
const mergeCtxThemeConfig = (ctx: Hexo) => {
  // Merge hexo.config.theme_config into hexo.theme.config before post rendering & generating
  // config.theme_config has "_config.[theme].yml" merged in load_theme_config.js
  if (ctx.config.theme_config) {
    ctx.theme.config = deepMerge(ctx.theme.config, ctx.config.theme_config);
  }
};

// eslint-disable-next-line no-use-before-define
const createLoadThemeRoute = function(generatorResult: NormalPageGenerator | NormalPostGenerator, locals: LocalsType, ctx: Hexo) {
  const { log, theme } = ctx;
  const { path, cache: useCache } = locals;

  const layout: string[] = [...new Set(castArray(generatorResult.layout))];
  const layoutLength = layout.length;

  // always use cache in fragment_cache
  locals.cache = true;
  return () => {
    if (useCache && routeCache.has(generatorResult)) return routeCache.get(generatorResult);

    for (let i = 0; i < layoutLength; i++) {
      const name = layout[i];
      const view = theme.getView(name);

      if (view) {
        log.debug(`Rendering HTML ${name}: ${magenta(path)}`);
        return view.render(locals)
          .then(result => ctx.extend.injector.exec(result, locals))
          .then(result => ctx.execFilter('_after_html_render', result, {
            context: ctx,
            args: [locals]
          }))
          .tap(result => {
            if (useCache) {
              routeCache.set(generatorResult, result);
            }
          }).tapCatch(err => {
            log.error({ err }, `Render HTML failed: ${magenta(path)}`);
          });
      }
    }

    log.warn(`No layout: ${magenta(path)}`);
  };
};

function debounce(func: () => void, wait: number): () => void {
  let timeout: NodeJS.Timeout;
  return function() {
    clearTimeout(timeout);
    timeout = setTimeout(() => {
      func.apply(this);
    }, wait);
  };
}

interface Args {
  debug?: boolean;
  safe?: boolean;
  silent?: boolean;
  draft?: boolean;
  drafts?: boolean;
  _?: string[];
  output?: string;
  config?: string;
  [key: string]: any;
}

interface Query {
  date?: any;
  published?: boolean;
}

interface Extend {
  console: Console,
  deployer: Deployer,
  filter: Filter,
  generator: Generator,
  helper: Helper,
  highlight: Highlight,
  injector: Injector,
  migrator: Migrator,
  processor: Processor,
  renderer: Renderer,
  tag: Tag
}

interface Env {
  args: Args;
  debug: boolean;
  safe: boolean;
  silent: boolean;
  env: string;
  version: string;
  cmd: string;
  init: boolean;
}

type DefaultConfigType = typeof defaultConfig;
interface Config extends DefaultConfigType {
  [key: string]: any;
}

// Node.js internal APIs
declare module 'module' {
  function _nodeModulePaths(path: string): string[];
  function _resolveFilename(request: string, parent: Module, isMain?: any, options?: any): string;
  const _extensions: NodeJS.RequireExtensions,
    _cache: any;
}

interface Hexo {

  /**
   * Emitted before deployment begins.
   * @param event
   * @param listener
   * @link https://hexo.io/api/events.html#deployBefore
   */
  on(event: 'deployBefore', listener: (...args: any[]) => any): this;

  /**
   * Emitted after deployment begins.
   * @param event
   * @param listener
   * @link https://hexo.io/api/events.html#deployAfter
   */
  on(event: 'deployAfter', listener: (...args: any[]) => any): this;

  /**
   * Emitted before Hexo exits.
   * @param event
   * @param listener
   * @link https://hexo.io/api/events.html#exit
   */
  on(event: 'exit', listener: (...args: any[]) => any): this;

  /**
   * Emitted before generation begins.
   * @param event
   * @param listener
   * @link https://hexo.io/api/events.html#generateBefore
   */
  on(event: 'generateBefore', listener: (...args: any[]) => any): this;

  /**
   * Emitted after generation finishes.
   * @param event
   * @param listener
   * @link https://hexo.io/api/events.html#generateAfter
   */
  on(event: 'generateAfter', listener: (...args: any[]) => any): this;

  /**
   * Emitted after a new post has been created. This event returns the post data:
   * @param event
   * @param listener
   * @link https://hexo.io/api/events.html#new
   */
  on(event: 'new', listener: (post: { path: string; content: string; }) => any): this;

  /**
   * Emitted before processing begins. This event returns a path representing the root directory of the box.
   * @param event
   * @param listener
   * @link https://hexo.io/api/events.html#processBefore
   */
  on(event: 'processBefore', listener: (...args: any[]) => any): this;

  /**
   * Emitted after processing finishes. This event returns a path representing the root directory of the box.
   * @param event
   * @param listener
   * @link https://hexo.io/api/events.html#processAfter
   */
  on(event: 'processAfter', listener: (...args: any[]) => any): this;

  /**
   * Emitted after initialization finishes.
   * @param event
   * @param listener
   */
  on(event: 'ready', listener: (...args: any[]) => any): this;

  /**
   * undescripted on emit
   * @param event
   * @param listener
   */
  on(event: string, listener: (...args: any[]) => any): any;
  emit(event: string, ...args: any[]): any;
}

class Hexo extends EventEmitter {
  public base_dir: string;
  public public_dir: string;
  public source_dir: string;
  public plugin_dir: string;
  public script_dir: string;
  public scaffold_dir: string;
  public theme_dir: string;
  public theme_script_dir: string;
  public env: Env;
  public extend: Extend;
  public config: Config;
  public log: ReturnType<typeof logger>;
  public render: Render;
  public route: Router;
  public post: Post;
  public scaffold: Scaffold;
  public _dbLoaded: boolean;
  public _isGenerating: boolean;
  public database: Database;
  public config_path: string;
  public source: Source;
  public theme: Theme;
  public locals: Locals;
  public version: string;
  public _watchBox: () => void;
  public lib_dir: string;
  public core_dir: string;
  static lib_dir: string;
  static core_dir: string;
  static version: string;

  constructor(base = process.cwd(), args: 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
    };

    this.extend = {
      console: new Console(),
      deployer: new Deployer(),
      filter: new Filter(),
      generator: new Generator(),
      helper: new Helper(),
      highlight: new Highlight(),
      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;

    if (/^(init|new|g|publish|s|deploy|render|migrate)/.test(this.env.cmd)) {
      this.log.d(`Writing database to ${join(dbPath, 'db.json')}`);
    }

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

    registerModels(this);

    this.source = new Source(this);
    this.theme = new Theme(this);
    this.locals = new Locals();
    this._bindLocals();
  }

  _bindLocals(): void {
    const db = this.database;
    const { locals } = this;

    locals.set('posts', () => {
      const query: Query = {};

      if (!this.config.future) {
        query.date = { $lte: Date.now() };
      }

      if (!this._showDrafts()) {
        query.published = true;
      }

      return db.model('Post').find(query);
    });

    locals.set('pages', () => {
      const query: Query = {};

      if (!this.config.future) {
        query.date = { $lte: Date.now() };
      }

      return db.model('Page').find(query);
    });

    locals.set('categories', () => {
      // Ignore categories with zero posts
      return db.model('Category').filter(category => category.length);
    });

    locals.set('tags', () => {
      // Ignore tags with zero posts
      return db.model('Tag').filter(tag => tag.length);
    });

    locals.set('data', () => {
      const obj = {};

      db.model('Data').forEach(data => {
        obj[data._id] = data.data;
      });

      return obj;
    });
  }

  init(): Promise<void> {
    this.log.debug('Hexo version: %s', magenta(this.version));
    this.log.debug('Working directory: %s', magenta(tildify(this.base_dir)));

    // Load internal plugins
    require('../plugins/console')(this);
    require('../plugins/filter')(this);
    require('../plugins/generator')(this);
    require('../plugins/helper')(this);
    require('../plugins/highlight')(this);
    require('../plugins/injector')(this);
    require('../plugins/processor')(this);
    require('../plugins/renderer')(this);
    require('../plugins/tag').default(this);

    // Load config
    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');
    });
  }

  call(name: string, callback?: NodeJSLikeCallback<any>): Promise<any>;
  call(name: string, args: object, callback?: NodeJSLikeCallback<any>): Promise<any>;
  call(name: string, args?: object | NodeJSLikeCallback<any>, callback?: NodeJSLikeCallback<any>): Promise<any> {
    if (!callback && typeof args === 'function') {
      callback = args as NodeJSLikeCallback<any>;
      args = {};
    }

    const c = this.extend.console.get(name);

    if (c) return (Reflect.apply(c, this, [args]) as any).asCallback(callback);
    return Promise.reject(new Error(`Console \`${name}\` has not been registered yet!`));
  }

  model(name: string, schema?: Schema | Record<string, AddSchemaTypeOptions>) {
    return this.database.model(name, schema);
  }

  resolvePlugin(name: string, basedir: string): string {
    try {
      // Try to resolve the plugin with the Node.js's built-in require.resolve.
      return require.resolve(name, { paths: [basedir] });
    } catch (err) {
      try {
        // There was an error (likely the node_modules is corrupt or from early version of npm)
        // Use Hexo prior 6.0.0's behavior (resolve.sync) to resolve the plugin.
        resolveSync = resolveSync || require('resolve').sync;
        return resolveSync(name, { basedir });
      } catch (err) {
        // There was an error (likely the plugin wasn't found), so return a possibly
        // non-existing path that a later part of the resolution process will check.
        return join(basedir, 'node_modules', name);
      }
    }
  }

  loadPlugin(path: string, callback?: NodeJSLikeCallback<any>): Promise<any> {
    return readFile(path).then(script => {
      // Based on: https://github.com/nodejs/node-v0.x-archive/blob/v0.10.33/src/node.js#L516
      const module = new Module(path);
      module.filename = path;
      module.paths = Module._nodeModulePaths(path);

      function req(path: string) {
        return module.require(path);
      }

      req.resolve = (request: string) => Module._resolveFilename(request, module);

      req.main = require.main;
      req.extensions = Module._extensions;
      req.cache = Module._cache;

      script = `(async function(exports, require, module, __filename, __dirname, hexo){${script}\n});`;

      const fn = runInThisContext(script, path);

      return fn(module.exports, req, module, path, dirname(path), this);
    }).asCallback(callback);
  }

  _showDrafts(): boolean {
    const { args } = this.env;
    return args.draft || args.drafts || this.config.render_drafts;
  }

  load(callback?: NodeJSLikeCallback<any>): Promise<any> {
    return loadDatabase(this).then(() => {
      this.log.info('Start processing');

      return Promise.all([
        this.source.process(),
        this.theme.process()
      ]);
    }).then(() => {
      mergeCtxThemeConfig(this);
      return this._generate({ cache: false });
    }).asCallback(callback);
  }

  watch(callback?: NodeJSLikeCallback<any>): Promise<any> {
    let useCache = false;
    const { cache } = Object.assign({
      cache: false
    }, this.config.server);
    const { alias } = this.extend.console;

    if (alias[this.env.cmd] === 'server' && cache) {
      // enable cache when run hexo server
      useCache = true;
    }
    this._watchBox = debounce(() => this._generate({ cache: useCache }), 100);

    return loadDatabase(this).then(() => {
      this.log.info('Start processing');

      return Promise.all([
        this.source.watch(),
        this.theme.watch()
      ]);
    }).then(() => {
      mergeCtxThemeConfig(this);

      this.source.on('processAfter', this._watchBox);
      this.theme.on('processAfter', () => {
        this._watchBox();
        mergeCtxThemeConfig(this);
      });

      return this._generate({ cache: useCache });
    }).asCallback(callback);
  }

  unwatch(): void {
    if (this._watchBox != null) {
      this.source.removeListener('processAfter', this._watchBox);
      this.theme.removeListener('processAfter', this._watchBox);

      this._watchBox = null;
    }

    stopWatcher(this.source);
    stopWatcher(this.theme);
  }

  _generateLocals() {
    const { config, env, theme, theme_dir } = this;
    const ctx = { config: { url: this.config.url } };
    const localsObj = this.locals.toObject() as SiteLocals;

    class Locals {
      page: NormalPageGenerator | NormalPostGenerator;
      path: string;
      url: string;
      config: any;
      theme: any;
      layout: string;
      env: any;
      view_dir: string;
      site: SiteLocals;
      cache?: boolean;

      constructor(path: string, locals: NormalPageGenerator | NormalPostGenerator) {
        this.page = { ...locals };
        if (this.page.path == null) this.page.path = path;
        this.path = path;
        this.url = full_url_for.call(ctx, path);
        this.config = config;
        this.theme = theme.config;
        this.layout = 'layout';
        this.env = env;
        this.view_dir = join(theme_dir, 'layout') + sep;
        this.site = localsObj;
      }
    }

    return Locals;
  }

  _runGenerators(): Promise<(AssetGenerator | PostGenerator | PageGenerator)[]> {
    this.locals.invalidate();
    const siteLocals = this.locals.toObject() as SiteLocals;
    const generators = this.extend.generator.list();
    const { log } = this;

    // Run generators
    return Promise.map(Object.keys(generators), key => {
      const generator = generators[key];

      log.debug('Generator: %s', magenta(key));
      return Reflect.apply(generator, this, [siteLocals]);
    }).reduce((result, data) => {
      return data ? result.concat(data) : result;
    }, []);
  }

  _routerRefresh(runningGenerators: Promise<(AssetGenerator | PostGenerator | PageGenerator)[]>, useCache: boolean): Promise<void> {
    const { route } = this;
    const routeList = route.list();
    const Locals = this._generateLocals();
    Locals.prototype.cache = useCache;

    return runningGenerators.map((generatorResult: AssetGenerator | PostGenerator | PageGenerator) => {
      if (typeof generatorResult !== 'object' || generatorResult.path == null) return undefined;

      // add Route
      const path = route.format(generatorResult.path);
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-ignore
      const { data, layout } = generatorResult;

      if (!layout) {
        route.set(path, data);
        return path;
      }

      return this.execFilter('template_locals', new Locals(path, data as unknown as NormalPageGenerator | NormalPostGenerator), { context: this })
        .then(locals => { route.set(path, createLoadThemeRoute(generatorResult as NormalPageGenerator | NormalPostGenerator, locals, this)); })
        .thenReturn(path);
    }).then(newRouteList => {
      // Remove old routes
      for (let i = 0, len = routeList.length; i < len; i++) {
        const item = routeList[i];

        if (!newRouteList.includes(item)) {
          route.remove(item);
        }
      }
    });
  }

  _generate(options: { cache?: boolean } = {}): Promise<any> {
    if (this._isGenerating) return;

    const useCache = options.cache;

    this._isGenerating = true;

    this.emit('generateBefore');

    // Run before_generate filters
    return this.execFilter('before_generate', null, { context: this })
      .then(() => this._routerRefresh(this._runGenerators(), useCache)).then(() => {
        this.emit('generateAfter');

        // Run after_generate filters
        return this.execFilter('after_generate', null, { context: this });
      }).finally(() => {
        this._isGenerating = false;
      });
  }

  exit(err?: any): Promise<void> {
    if (err) {
      this.log.fatal(
        { err },
        'Something\'s wrong. Maybe you can find the solution here: %s',
        underline('https://hexo.io/docs/troubleshooting.html')
      );
    }

    return this.execFilter('before_exit', null, { context: this }).then(() => {
      this.emit('exit', err);
    });
  }

  execFilter(type: string, data: any, options?) {
    return this.extend.filter.exec(type, data, options);
  }

  execFilterSync(type: string, data: any, options?) {
    return this.extend.filter.execSync(type, data, options);
  }
}

Hexo.lib_dir = libDir + sep;
Hexo.prototype.lib_dir = Hexo.lib_dir;

Hexo.core_dir = dirname(libDir) + sep;
Hexo.prototype.core_dir = Hexo.core_dir;

Hexo.version = version;
Hexo.prototype.version = Hexo.version;

// define global variable
// this useful for plugin written in typescript
declare global {
  // eslint-disable-next-line one-var
  const hexo: Hexo;
}

export = Hexo;