Reference Source Test

src/ESDoc.js

import fs from 'fs-extra';
import path from 'path';
import assert from 'assert';
import ASTUtil from './Util/ASTUtil.js';
import ESParser from './Parser/ESParser';
import PathResolver from './Util/PathResolver.js';
import DocFactory from './Factory/DocFactory.js';
import InvalidCodeLogger from './Util/InvalidCodeLogger.js';
import Plugin from './Plugin/Plugin.js';
import {Transform} from 'stream';
import json from 'big-json';
import mkdirp from 'mkdirp';
import log from 'npmlog';

/**
 * API Documentation Generator.
 *
 * @example
 * let config = {source: './src', destination: './esdoc2'};
 * esdoc2.generate(config, (results, config)=>{
 *   console.log(results);
 * });
 */
export default class ESDoc {
  /**
   * Generate documentation.
   * @param {ESDocConfig} config - config for generation.
   */
  static generate(config) {
    return new Promise((resolve) => {
      assert(config.source);
      assert(config.destination);

      this._checkOldConfig(config);

      Plugin.init(config.plugins);
      Plugin.onStart();
      config = Plugin.onHandleConfig(config);

      this._setDefaultConfig(config);

      const includes = config.includes.map((v) => new RegExp(v));
      const excludes = config.excludes.map((v) => new RegExp(v));

      let packageName = null;
      let mainFilePath = null;
      if (config.package) {
        try {
          const packageJSON = fs.readFileSync(config.package, {encode: 'utf8'});
          const packageConfig = JSON.parse(packageJSON);
          packageName = packageConfig.name;
          mainFilePath = packageConfig.main;
        } catch (e) {
        // ignore
        }
      }

      let results = [];
      const sourceDirPath = path.resolve(config.source);
      const onWriteFinish = () => {
        log.info('esdoc2', 'finished generating files');

        // publish
        this._publish(config);

        Plugin.onComplete();

        this._memUsage();
        resolve(true);
      };

      const fatalError = err => {
        log.error(err);
        process.exit(1);
      };

      const stringifyWriteTransform = new Transform({
        writableObjectMode: true,
        readableObjectMode: true,
        transform: function(chunk, encoding, transformCallback) {
          const fullPath = path.resolve(config.destination, `ast/${chunk.filePath}.json`);
          mkdirp(fullPath.split('/').slice(0, -1).join('/'), (err) => {
            if (err) fatalError(err);
            log.verbose('transform', fullPath);
            const fileWriteStream = fs.createWriteStream(fullPath);
            fileWriteStream.on('error', fatalError);
            fileWriteStream.on('finish', () => log.verbose('write', fullPath));
            fileWriteStream.on('finish', transformCallback);

            const stringifyStream = json.createStringifyStream({body: chunk.ast});
            stringifyStream.on('error', fatalError);
            stringifyStream.on('end', fileWriteStream.end);
            stringifyStream.on('end', () => log.verbose('stringified', fullPath));
            stringifyStream.pipe(fileWriteStream);
          });
        }
      });

      stringifyWriteTransform.on('finish', onWriteFinish);
      stringifyWriteTransform.on('error', fatalError);

      this._walk(config.source, (filePath) => {
        const relativeFilePath = path.relative(sourceDirPath, filePath);
        let match = false;
        for (const reg of includes) {
          if (relativeFilePath.match(reg)) {
            match = true;
            break;
          }
        }
        if (!match) return;

        for (const reg of excludes) {
          if (relativeFilePath.match(reg)) return;
        }

        log.info('parse', filePath);
        const temp = this._traverse(config.source, filePath, packageName, mainFilePath);
        if (!temp) return;
        results.push(...temp.results);
        stringifyWriteTransform.write({filePath: `source${path.sep}${relativeFilePath}`, ast: temp.ast});
      });

      stringifyWriteTransform.end();

      // config.index
      if (config.index) {
        results.push(this._generateForIndex(config));
      }

      // config.package
      if (config.package) {
        results.push(this._generateForPackageJSON(config));
      }

      results = this._resolveDuplication(results);

      results = Plugin.onHandleDocs(results);

      // index.json
      {
        const dumpPath = path.resolve(config.destination, 'index.json');
        fs.outputFileSync(dumpPath, JSON.stringify(results, null, 2));
      }
    });
  }

  /**
   * check esdoc2 config. and if it is old, exit with warning message.
   * @param {ESDocConfig} config - check config
   * @private
   */
  static _checkOldConfig(config) {
    let exit = false;

    const keys = [
      ['access', 'esdoc2-standard-plugin'],
      ['autoPrivate', 'esdoc2-standard-plugin'],
      ['unexportIdentifier', 'esdoc2-standard-plugin'],
      ['undocumentIdentifier', 'esdoc2-standard-plugin'],
      ['builtinExternal', 'esdoc2-standard-plugin'],
      ['coverage', 'esdoc2-standard-plugin'],
      ['test', 'esdoc2-standard-plugin'],
      ['title', 'esdoc2-standard-plugin'],
      ['manual', 'esdoc2-standard-plugin'],
      ['lint', 'esdoc2-standard-plugin'],
      ['includeSource', 'esdoc2-exclude-source-plugin'],
      ['styles', 'esdoc2-inject-style-plugin'],
      ['scripts', 'esdoc2-inject-script-plugin'],
      ['experimentalProposal', 'esdoc2-ecmascript-proposal-plugin']
    ];

    for (const [key, plugin] of keys) {
      if (key in config) {
        console.error(`error: config.${key} is invalid. Please use ${plugin}. how to migration: https://esdoc2.org/manual/migration.html`);
        exit = true;
      }
    }

    if (exit) process.exit(1);
  }

  /**
   * set default config to specified config.
   * @param {ESDocConfig} config - specified config.
   * @private
   */
  static _setDefaultConfig(config) {
    if (!config.includes) config.includes = ['\\.js$'];

    if (!config.excludes) config.excludes = ['\\.config\\.js$', '\\.test\\.js$'];

    if (!config.index) config.index = './README.md';

    if (!config.package) config.package = './package.json';
  }

  /**
   * walk recursive in directory.
   * @param {string} dirPath - target directory path.
   * @param {function(entryPath: string)} callback - callback for find file.
   * @private
   */
  static _walk(dirPath, callback) {
    const entries = fs.readdirSync(dirPath);

    for (const entry of entries) {
      const entryPath = path.resolve(dirPath, entry);
      const stat = fs.statSync(entryPath);

      if (stat.isFile()) {
        callback(entryPath);
      } else if (stat.isDirectory()) {
        this._walk(entryPath, callback);
      }
    }
  }

  /**
   * traverse doc comment in JavaScript file.
   * @param {string} inDirPath - root directory path.
   * @param {string} filePath - target JavaScript file path.
   * @param {string} [packageName] - npm package name of target.
   * @param {string} [mainFilePath] - npm main file path of target.
   * @returns {Object} - return document that is traversed.
   * @property {DocObject[]} results - this is contained JavaScript file.
   * @property {AST} ast - this is AST of JavaScript file.
   * @private
   */
  static _traverse(inDirPath, filePath, packageName, mainFilePath) {
    log.verbose(`parsing: ${filePath}`);
    let ast;
    try {
      ast = ESParser.parse(filePath);
    } catch (e) {
      InvalidCodeLogger.showFile(filePath, e);
      return null;
    }

    const pathResolver = new PathResolver(inDirPath, filePath, packageName, mainFilePath);
    const factory = new DocFactory(ast, pathResolver);

    ASTUtil.traverse(ast, (node, parent) => {
      try {
        factory.push(node, parent);
      } catch (e) {
        InvalidCodeLogger.show(filePath, node);
        throw e;
      }
    });

    return {results: factory.results, ast: ast};
  }

  /**
   * generate index doc
   * @param {ESDocConfig} config
   * @returns {Tag}
   * @private
   */
  static _generateForIndex(config) {
    const indexContent = fs.readFileSync(config.index, {encode: 'utf8'}).toString();
    const tag = {
      kind: 'index',
      content: indexContent,
      longname: path.resolve(config.index),
      name: config.index,
      static: true,
      access: 'public'
    };

    return tag;
  }

  /**
   * generate package doc
   * @param {ESDocConfig} config
   * @returns {Tag}
   * @private
   */
  static _generateForPackageJSON(config) {
    let packageJSON = '';
    let packagePath = '';
    try {
      packageJSON = fs.readFileSync(config.package, {encoding: 'utf-8'});
      packagePath = path.resolve(config.package);
    } catch (e) {
      // ignore
    }

    const tag = {
      kind: 'packageJSON',
      content: packageJSON,
      longname: packagePath,
      name: path.basename(packagePath),
      static: true,
      access: 'public'
    };

    return tag;
  }

  /**
   * resolve duplication docs
   * @param {Tag[]} docs
   * @returns {Tag[]}
   * @private
   */
  static _resolveDuplication(docs) {
    const memberDocs = docs.filter((doc) => doc.kind === 'member');
    const removeIds = [];

    for (const memberDoc of memberDocs) {
      // member duplicate with getter/setter/method.
      // when it, remove member.
      // getter/setter/method are high priority.
      const sameLongnameDoc = docs.find((doc) => doc.longname === memberDoc.longname && doc.kind !== 'member');
      if (sameLongnameDoc) {
        removeIds.push(memberDoc.__docId__);
        continue;
      }

      const dup = docs.filter((doc) => doc.longname === memberDoc.longname && doc.kind === 'member');
      if (dup.length > 1) {
        const ids = dup.map(v => v.__docId__);
        ids.sort((a, b) => {
          return a < b ? -1 : 1;
        });
        ids.shift();
        removeIds.push(...ids);
      }
    }

    return docs.filter((doc) => !removeIds.includes(doc.__docId__));
  }

  /**
   * publish content
   * @param {ESDocConfig} config
   * @private
   */
  static _publish(config) {
    try {
      const write = (filePath, content, option) => {
        const _filePath = path.resolve(config.destination, filePath);
        content = Plugin.onHandleContent(content, _filePath);
        
        log.info('write', _filePath);
        fs.outputFileSync(_filePath, content, option);
      };

      const copy = (srcPath, destPath) => {
        const _destPath = path.resolve(config.destination, destPath);
        log.info('copy', _destPath);
        fs.copySync(srcPath, _destPath);
      };

      const read = (filePath) => {
        const _filePath = path.resolve(config.destination, filePath);
        return fs.readFileSync(_filePath).toString();
      };

      Plugin.onPublish(write, copy, read);
    } catch (e) {
      InvalidCodeLogger.showError(e);
      process.exit(1);
    }
  }

  /**
   * show memory usage stat
   * @return {undefined} no return
   */
  static _memUsage() {
    const used = process.memoryUsage();
    Object.keys(used).forEach(key => {
      log.verbose(`${key} ${Math.round(used[key] / 1024 / 1024 * 100) / 100} MB`);
    });
  }
}