lib/box/index.ts

Summary

Maintainability
A
3 hrs
Test Coverage
import { join, sep } from 'path';
import BlueBirdPromise from 'bluebird';
import File from './file';
import { Pattern, createSha1Hash } from 'hexo-util';
import { createReadStream, readdir, stat, watch } from 'hexo-fs';
import { magenta } from 'picocolors';
import { EventEmitter } from 'events';
import { isMatch, makeRe } from 'micromatch';
import type Hexo from '../hexo';
import type { NodeJSLikeCallback } from '../types';

const defaultPattern = new Pattern(() => ({}));

interface Processor {
  pattern: Pattern;
  process: (file?: File) => any;
}

class Box extends EventEmitter {
  public options: any;
  public context: Hexo;
  public base: string;
  public processors: Processor[];
  public _processingFiles: any;
  public watcher: any;
  public Cache: any;
  // TODO: replace runtime class _File
  public File: any;
  public ignore: any[];
  public source: any;

  constructor(ctx: Hexo, base: string, options?: object) {
    super();

    this.options = Object.assign({
      persistent: true,
      awaitWriteFinish: {
        stabilityThreshold: 200
      }
    }, options);

    if (!base.endsWith(sep)) {
      base += sep;
    }

    this.context = ctx;
    this.base = base;
    this.processors = [];
    this._processingFiles = {};
    this.watcher = null;
    this.Cache = ctx.model('Cache');
    this.File = this._createFileClass();
    let targets = this.options.ignored || [];
    if (ctx.config.ignore && ctx.config.ignore.length) {
      targets = targets.concat(ctx.config.ignore);
    }
    this.ignore = targets;
    this.options.ignored = targets.map(s => toRegExp(ctx, s)).filter(x => x);
  }

  _createFileClass() {
    const ctx = this.context;

    class _File extends File {
      public box: Box;

      render(options?: object) {
        return ctx.render.render({
          path: this.source
        }, options);
      }

      renderSync(options?: object) {
        return ctx.render.renderSync({
          path: this.source
        }, options);
      }
    }

    _File.prototype.box = this;

    return _File;
  }

  addProcessor(pattern: (...args: any[]) => any): void;
  addProcessor(pattern: string | RegExp | Pattern | ((...args: any[]) => any), fn: (...args: any[]) => any): void;
  addProcessor(pattern: string | RegExp | Pattern | ((...args: any[]) => any), fn?: (...args: any[]) => any): void {
    if (!fn && typeof pattern === 'function') {
      fn = pattern;
      pattern = defaultPattern;
    }

    if (typeof fn !== 'function') throw new TypeError('fn must be a function');
    if (!(pattern instanceof Pattern)) pattern = new Pattern(pattern);

    this.processors.push({
      pattern,
      process: fn
    });
  }

  _readDir(base: string, prefix = ''): BlueBirdPromise<any> {
    const { context: ctx } = this;
    const results = [];
    return readDirWalker(ctx, base, results, this.ignore, prefix)
      .return(results)
      .map(path => this._checkFileStatus(path))
      .map(file => this._processFile(file.type, file.path).return(file.path));
  }

  _checkFileStatus(path: string) {
    const { Cache, context: ctx } = this;
    const src = join(this.base, path);

    return Cache.compareFile(
      src.substring(ctx.base_dir.length),
      () => getHash(src),
      () => stat(src)
    ).then(result => ({
      type: result.type,
      path
    }));
  }

  process(callback?: NodeJSLikeCallback<any>): BlueBirdPromise<any> {
    const { base, Cache, context: ctx } = this;

    return stat(base).then(stats => {
      if (!stats.isDirectory()) return;

      // Check existing files in cache
      const relativeBase = base.substring(ctx.base_dir.length);
      const cacheFiles = Cache.filter(item => item._id.startsWith(relativeBase)).map(item => item._id.substring(relativeBase.length));

      // Handle deleted files
      return this._readDir(base)
        .then((files: string[]) => cacheFiles.filter((path: string) => !files.includes(path)))
        .map((path: string) => this._processFile(File.TYPE_DELETE, path) as PromiseLike<any>);
    }).catch(err => {
      if (err && err.code !== 'ENOENT') throw err;
    }).asCallback(callback);
  }

  _processFile(type: string, path: string): BlueBirdPromise<void> | BlueBirdPromise<string> {
    if (this._processingFiles[path]) {
      return BlueBirdPromise.resolve();
    }

    this._processingFiles[path] = true;

    const { base, File, context: ctx } = this;

    this.emit('processBefore', {
      type,
      path
    });

    return BlueBirdPromise.reduce(this.processors, (count, processor) => {
      // patten supports *nix style path only, replace backslashes on Windows
      const params = processor.pattern.match(escapeBackslash(path));
      if (!params) return count;

      const file = new File({
        // source is used for file system path, keep backslashes on Windows
        source: join(base, path),
        // path is used for URL path, replace backslashes on Windows
        path: escapeBackslash(path),
        params,
        type
      });

      return Reflect.apply(BlueBirdPromise.method(processor.process), ctx, [file])
        .thenReturn(count + 1);
    }, 0).then(count => {
      if (count) {
        ctx.log.debug('Processed: %s', magenta(path));
      }

      this.emit('processAfter', {
        type,
        path
      });
    }).catch(err => {
      ctx.log.error({ err }, 'Process failed: %s', magenta(path));
    }).finally(() => {
      this._processingFiles[path] = false;
    }).thenReturn(path);
  }

  watch(callback?: NodeJSLikeCallback<never>): BlueBirdPromise<void> {
    if (this.isWatching()) {
      return BlueBirdPromise.reject(new Error('Watcher has already started.')).asCallback(callback);
    }

    const { base } = this;

    function getPath(path) {
      return path.substring(base.length);
    }

    return this.process().then(() => watch(base, this.options)).then(watcher => {
      this.watcher = watcher;

      watcher.on('add', path => {
        this._processFile(File.TYPE_CREATE, getPath(path));
      });

      watcher.on('change', path => {
        this._processFile(File.TYPE_UPDATE, getPath(path));
      });

      watcher.on('unlink', path => {
        this._processFile(File.TYPE_DELETE, getPath(path));
      });

      watcher.on('addDir', path => {
        let prefix = getPath(path);
        if (prefix) prefix += sep;

        this._readDir(path, prefix);
      });
    }).asCallback(callback);
  }

  unwatch(): void {
    if (!this.isWatching()) return;

    this.watcher.close();
    this.watcher = null;
  }

  isWatching(): boolean {
    return Boolean(this.watcher);
  }
}

function escapeBackslash(path: string): string {
  // Replace backslashes on Windows
  return path.replace(/\\/g, '/');
}

function getHash(path: string): BlueBirdPromise<string> {
  const src = createReadStream(path);
  const hasher = createSha1Hash();

  const finishedPromise = new BlueBirdPromise((resolve, reject) => {
    src.once('error', reject);
    src.once('end', resolve);
  });

  src.on('data', chunk => { hasher.update(chunk); });

  return finishedPromise.then(() => hasher.digest('hex'));
}

function toRegExp(ctx: Hexo, arg: string): RegExp | null {
  if (!arg) return null;
  if (typeof arg !== 'string') {
    ctx.log.warn('A value of "ignore:" section in "_config.yml" is not invalid (not a string)');
    return null;
  }
  const result = makeRe(arg);
  if (!result) {
    ctx.log.warn('A value of "ignore:" section in "_config.yml" can not be converted to RegExp:' + arg);
    return null;
  }
  return result;
}

function isIgnoreMatch(path: string, ignore: string | any[]): boolean {
  return path && ignore && ignore.length && isMatch(path, ignore);
}

function readDirWalker(ctx: Hexo, base: string, results: any[], ignore: any, prefix: string): BlueBirdPromise<any> {
  if (isIgnoreMatch(base, ignore)) return BlueBirdPromise.resolve();

  return BlueBirdPromise.map(readdir(base).catch(err => {
    ctx.log.error({ err }, 'Failed to read directory: %s', base);
    if (err && err.code === 'ENOENT') return [];
    throw err;
  }), async path => {
    const fullpath = join(base, path);
    const stats = await stat(fullpath).catch(err => {
      ctx.log.error({ err }, 'Failed to stat file: %s', fullpath);
      if (err && err.code === 'ENOENT') return null;
      throw err;
    });
    const prefixPath = `${prefix}${path}`;
    if (stats) {
      if (stats.isDirectory()) {
        return readDirWalker(ctx, fullpath, results, ignore, prefixPath + sep);
      }
      if (!isIgnoreMatch(fullpath, ignore)) {
        results.push(prefixPath);
      }
    }
  });
}

export interface _File extends File {
  box: Box;
  render(options?: any): any;
  renderSync(options?: any): any;
}

export default Box;