Home Manual Reference Source Test Repository

src/Publisher/Builder/DocBuilder.js

import fs from 'fs';
import path from 'path';
import escape from 'escape-html';
import IceCap from 'ice-cap';
import {shorten, parseExample} from './util.js';
import DocResolver from './DocResolver.js';
import NPMUtil from '../../Util/NPMUtil.js';

/**
 * Builder base class.
 */
export default class DocBuilder {
  /**
   * create instance.
   * @param {Taffy} data - doc object database.
   * @param {ESDocConfig} config - esdoc config is used build output.
   */
  constructor(data, config) {
    this._data = data;
    this._config = config;
    new DocResolver(this).resolve();
  }

  /**
   * execute building output.
   * @abstract
   * @param {function} callback - is called with some data.
   */
  exec(callback) {
  }

  /**
   * find doc object.
   * @param {...Object} cond - find condition.
   * @returns {DocObject[]} found doc objects.
   * @private
   */
  _find(...cond) {
    return this._orderedFind(null, ...cond);
  }

  /**
   * find all identifiers with kind grouping.
   * @returns {{class: DocObject[], interface: DocObject[], function: DocObject[], variable: DocObject[], typedef: DocObject[], external: DocObject[]}} found doc objects.
   * @private
   */
  _findAllIdentifiersKindGrouping() {
    const result = {
      class: this._find([{kind: 'class', interface: false}]),
      interface: this._find([{kind: 'class', interface: true}]),
      function: this._find([{kind: 'function'}]),
      variable: this._find([{kind: 'variable'}]),
      typedef: this._find([{kind: 'typedef'}]),
      external: this._find([{kind: 'external'}]).filter(v => !v.builtinExternal)
    };
    return result;
  }

  /**
   * fuzzy find doc object by name.
   * - equal with longname
   * - equal with name
   * - include in longname
   * - include in ancestor
   *
   * @param {string} name - target identifier name.
   * @param {string} [kind] - target kind.
   * @returns {DocObject[]} found doc objects.
   * @private
   */
  _findByName(name, kind = null) {
    let docs;

    if (kind) {
      docs = this._orderedFind(null, {longname: name, kind: kind});
    } else {
      docs = this._orderedFind(null, {longname: name});
    }
    if (docs.length) return docs;

    if (kind) {
      docs = this._orderedFind(null, {name: name, kind: kind});
    } else {
      docs = this._orderedFind(null, {name: name});
    }
    if (docs.length) return docs;

    let regexp = new RegExp(`[~]${name.replace('*', '\\*')}$`); // if name is `*`, need to escape.
    if (kind) {
      docs = this._orderedFind(null, {longname: {regex: regexp}, kind: kind});
    } else {
      docs = this._orderedFind(null, {longname: {regex: regexp}});
    }
    if (docs.length) return docs;

    // inherited method?
    let matched = name.match(/(.*)[.#](.*)$/); // instance method(Foo#bar) or static method(Foo.baz)
    if (matched) {
      let parent = matched[1];
      let childName = matched[2];
      let parentDoc = this._findByName(parent, 'class')[0];
      if (parentDoc && parentDoc._custom_extends_chains) {
        for (let superLongname of parentDoc._custom_extends_chains) {
          let docs = this._find({memberof: superLongname, name: childName});
          if (docs.length) return docs;
        }
      }
    }

    return [];
  }

  /**
   * find doc objects that is ordered.
   * @param {string} order - doc objects order(``column asec`` or ``column desc``).
   * @param {...Object} cond - condition objects
   * @returns {DocObject[]} found doc objects.
   * @private
   */
  _orderedFind(order, ...cond) {
    let data = this._data(...cond);

    if (order) {
      return data.order(order + ', name asec').map(v => v);
    } else {
      return data.order('name asec').map(v => v);
    }
  }

  /**
   * read html template.
   * @param {string} fileName - template file name.
   * @return {string} html of template.
   * @private
   */
  _readTemplate(fileName) {
    let filePath = path.resolve(__dirname, `./template/${fileName}`);
    return fs.readFileSync(filePath, {encoding: 'utf-8'});
  }

  /**
   * get target's essential info.
   * @returns {{title: string, version: string, url: string}}
   * @private
   */
  _getInfo() {
    let config = this._config;
    let packageObj = {};
    if (config.package) {
      let packagePath = config.package;
      try {
        let json = fs.readFileSync(packagePath, {encoding: 'utf-8'});
        packageObj = JSON.parse(json);
      } catch (e) {
        // ignore
      }
    }

    // repository url
    let url = null;
    if (packageObj.repository) {
      if (packageObj.repository.url) {
        url = packageObj.repository.url;
      } else {
        url = packageObj.repository;
      }

      if (typeof url === 'string') {
        if (url.indexOf('git@github.com:') === 0) { // url: git@github.com:foo/bar.git
          let matched = url.match(/^git@github\.com:(.*)\.git$/);
          if (matched && matched[1]) {
            url = `https://github.com/${matched[1]}`;
          }
        } else if (url.match(/^[\w\d\-_]+\/[\w\d\-_]+$/)) { // url: foo/bar
          url = `https://github.com/${url}`;
        } else if(url.match(/^git\+https:\/\/github.com\/.*\.git$/)) { // git+https://github.com/foo/bar.git
          const matched = url.match(/^git\+(https:\/\/github.com\/.*)\.git$/);
          url = matched[1];
        } else if(url.match(/(https?:\/\/.*$)/)){ // other url
          const matched = url.match(/(https?:\/\/.*$)/);
          url = matched[1];
        } else {
          url = '';
        }
      } else {
        url = null;
      }
    }

    let indexInfo = {
      title: config.title || packageObj.name,
      version: config.version || packageObj.version,
      url: url
    };

    return indexInfo;
  }

  /**
   * build common layout output.
   * @return {IceCap} layout output.
   * @private
   */
  _buildLayoutDoc() {
    let info = this._getInfo();

    let ice = new IceCap(this._readTemplate('layout.html'), {autoClose: false});

    let packageObj = NPMUtil.findPackage();
    if (packageObj) {
      ice.text('esdocVersion', `(${packageObj.version})`);
    } else {
      ice.drop('esdocVersion');
    }

    if (info.url) {
      ice.attr('repoURL', 'href', info.url);
      if (info.url.match(new RegExp('^https?://github.com/'))) {
        ice.attr('repoURL', 'class', 'repo-url-github');
      }
    } else {
      ice.drop('repoURL');
    }

    ice.drop('testLink', !this._config.test);

    // see StaticFileBuilder#exec
    ice.loop('userScript', this._config.scripts || [], (i, userScript, ice)=>{
      let name = `user/script/${i}-${path.basename(userScript)}`;
      ice.attr('userScript', 'src', name);
    });

    ice.loop('userStyle', this._config.styles || [], (i, userStyle, ice)=>{
      let name = `user/css/${i}-${path.basename(userStyle)}`;
      ice.attr('userStyle', 'href', name);
    });

    ice.drop('manualHeaderLink', !this._config.manual);

    ice.load('nav', this._buildNavDoc());
    return ice;
  }

  /**
   * build common navigation output.
   * @return {IceCap} navigation output.
   * @private
   */
  _buildNavDoc() {
    let html = this._readTemplate('nav.html');
    let ice = new IceCap(html);

    const kinds = ['class', 'function', 'variable', 'typedef', 'external'];
    const allDocs = this._find({kind: kinds}).filter(v => !v.builtinExternal);
    const kindOrder = {class: 0, interface: 1, function: 2, variable: 3, typedef: 4, external: 5};
    allDocs.sort((a, b)=>{
      const filePathA = a.longname.split('~')[0].replace('src/', '');
      const filePathB = b.longname.split('~')[0].replace('src/', '');
      const dirPathA = path.dirname(filePathA);
      const dirPathB = path.dirname(filePathB);
      const kindA = a.interface ? 'interface' : a.kind;
      const kindB = b.interface ? 'interface' : b.kind;
      if (dirPathA === dirPathB) {
        if (kindA === kindB) {
          return a.longname > b.longname ? 1 : -1;
        } else {
          return kindOrder[kindA] > kindOrder[kindB] ? 1 : -1;
        }
      } else {
        return dirPathA > dirPathB ? 1 : -1;
      }
    });
    let lastDirPath = '.';
    ice.loop('doc', allDocs, (i, doc, ice)=>{
      const filePath = doc.longname.split('~')[0].replace(/^.*?[/]/, '');
      const dirPath = path.dirname(filePath);
      const kind = doc.interface ? 'interface' : doc.kind;
      const kindText = kind.charAt(0).toUpperCase();
      const kindClass = `kind-${kind}`;
      ice.load('name', this._buildDocLinkHTML(doc.longname));
      ice.load('kind', kindText);
      ice.attr('kind', 'class', kindClass);
      ice.text('dirPath', dirPath);
      ice.drop('dirPath', lastDirPath === dirPath);
      lastDirPath = dirPath;
    });

    return ice;
  }

  /**
   * find doc object for each access.
   * @param {DocObject} doc - parent doc object.
   * @param {string} kind - kind property condition.
   * @param {boolean} isStatic - static property condition
   * @returns {Array[]} found doc objects.
   * @property {Array[]} 0 - ['Public', DocObject[]]
   * @property {Array[]} 1 - ['Protected', DocObject[]]
   * @property {Array[]} 2 - ['Private', DocObject[]]
   * @private
   */
  _findAccessDocs(doc, kind, isStatic = true) {
    let cond = {kind, static: isStatic};

    if (doc) cond.memberof = doc.longname;

    switch (kind) {
      case 'class':
        cond.interface = false;
        break;
      case 'interface':
        cond.kind = 'class';
        cond.interface = true;
        break;
      case 'member':
        cond.kind = ['member', 'get', 'set'];
        break;
    }

    let publicDocs = this._find(cond, {access: 'public'}).filter(v => !v.builtinExternal);
    let protectedDocs = this._find(cond, {access: 'protected'}).filter(v => !v.builtinExternal);
    let privateDocs = this._find(cond, {access: 'private'}).filter(v => !v.builtinExternal);
    let accessDocs = [['Public', publicDocs], ['Protected', protectedDocs], ['Private', privateDocs]];

    return accessDocs;
  }

  /**
   * build summary output html by parent doc.
   * @param {DocObject} doc - parent doc object.
   * @param {string} kind - target kind property.
   * @param {string} title - summary title.
   * @param {boolean} [isStatic=true] - target static property.
   * @returns {string} html of summary.
   * @private
   */
  _buildSummaryHTML(doc, kind, title, isStatic = true) {
    let accessDocs = this._findAccessDocs(doc, kind, isStatic);
    let html = '';
    for (let accessDoc of accessDocs) {
      let docs = accessDoc[1];
      if (!docs.length) continue;

      let prefix = '';
      if (docs[0].static) prefix = 'Static ';
      let _title = `${prefix}${accessDoc[0]} ${title}`;

      let result = this._buildSummaryDoc(docs, _title);
      if (result) {
        html += result.html;
      }
    }

    return html;
  }

  /**
   * build summary output html by docs.
   * @param {DocObject[]} docs - target docs.
   * @param {string} title - summary title.
   * @param {boolean} innerLink - if true, link in summary is inner link.
   * @return {IceCap} summary output.
   * @private
   */
  _buildSummaryDoc(docs, title, innerLink) {
    if (docs.length === 0) return;

    let ice = new IceCap(this._readTemplate('summary.html'));

    ice.text('title', title);
    ice.loop('target', docs, (i, doc, ice)=>{
      ice.text('generator', doc.generator ? '*' : '');
      ice.load('name', this._buildDocLinkHTML(doc.longname, null, innerLink, doc.kind));
      ice.load('signature', this._buildSignatureHTML(doc));
      ice.load('description', shorten(doc, true));
      ice.text('abstract', doc.abstract ? 'abstract' : '');
      ice.text('access', doc.access);
      if (['get', 'set'].includes(doc.kind)) {
        ice.text('kind', doc.kind);
      } else {
        ice.drop('kind');
      }

      if (['member', 'method', 'get', 'set'].includes(doc.kind)) {
        ice.text('static', doc.static ? 'static' : '');
      } else {
        ice.drop('static');
      }

      ice.text('since', doc.since);
      ice.load('deprecated', this._buildDeprecatedHTML(doc));
      ice.load('experimental', this._buildExperimentalHTML(doc));
      ice.text('version', doc.version);
    });

    return ice;
  }

  /**
   * build detail output html by parent doc.
   * @param {DocObject} doc - parent doc object.
   * @param {string} kind - target kind property.
   * @param {string} title - detail title.
   * @param {boolean} [isStatic=true] - target static property.
   * @returns {string} html of detail.
   * @private
   */
  _buildDetailHTML(doc, kind, title, isStatic = true) {
    let accessDocs = this._findAccessDocs(doc, kind, isStatic);
    let html = '';
    for (let accessDoc of accessDocs) {
      let docs = accessDoc[1];
      if (!docs.length) continue;

      let prefix = '';
      if (docs[0].static) prefix = 'Static ';
      let _title = `${prefix}${accessDoc[0]} ${title}`;

      let result = this._buildDetailDocs(docs, _title);
      if (result) html += result.html;
    }

    return html;
  }

  /**
   * build detail output html by docs.
   * @param {DocObject[]} docs - target docs.
   * @param {string} title - detail title.
   * @return {IceCap} detail output.
   * @private
   */
  _buildDetailDocs(docs, title) {
    let ice = new IceCap(this._readTemplate('details.html'));

    ice.text('title', title);
    ice.drop('title', !docs.length);

    ice.loop('detail', docs, (i, doc, ice)=>{
      let scope = doc.static ? 'static' : 'instance';
      ice.attr('anchor', 'id', `${scope}-${doc.kind}-${doc.name}`);
      ice.text('generator', doc.generator ? '*' : '');
      ice.text('name', doc.name);
      ice.load('signature', this._buildSignatureHTML(doc));
      ice.load('description', doc.description);
      ice.text('abstract', doc.abstract ? 'abstract' : '');
      ice.text('access', doc.access);
      if (['get', 'set'].includes(doc.kind)) {
        ice.text('kind', doc.kind);
      } else {
        ice.drop('kind');
      }
      if (doc.export && doc.importPath && doc.importStyle) {
        let link = this._buildFileDocLinkHTML(doc, doc.importPath);
        ice.into('importPath', `import ${doc.importStyle} from '${link}'`, (code, ice)=>{
          ice.load('importPathCode', code);
        });
      } else {
        ice.drop('importPath');
      }

      if (['member', 'method', 'get', 'set'].includes(doc.kind)) {
        ice.text('static', doc.static ? 'static' : '');
      } else {
        ice.drop('static');
      }

      ice.load('source', this._buildFileDocLinkHTML(doc, 'source'));
      ice.text('since', doc.since, 'append');
      ice.load('deprecated', this._buildDeprecatedHTML(doc));
      ice.load('experimental', this._buildExperimentalHTML(doc));
      ice.text('version', doc.version, 'append');
      ice.load('see', this._buildDocsLinkHTML(doc.see), 'append');
      ice.load('todo', this._buildDocsLinkHTML(doc.todo), 'append');
      ice.load('override', this._buildOverrideMethod(doc));

      let isFunction = false;
      if (['method', 'constructor', 'function'].indexOf(doc.kind) !== -1) isFunction = true;
      if (doc.kind === 'typedef' && doc.params && doc.type.types[0] === 'function') isFunction = true;

      if (isFunction) {
        ice.load('properties', this._buildProperties(doc.params, 'Params:'));
      } else {
        ice.load('properties', this._buildProperties(doc.properties, 'Properties:'));
      }

      // return
      if (doc.return) {
        ice.load('returnDescription', doc.return.description);
        let typeNames = [];
        for (let typeName of doc.return.types) {
          typeNames.push(this._buildTypeDocLinkHTML(typeName));
        }
        if (typeof doc.return.nullable === 'boolean') {
          let nullable = doc.return.nullable;
          ice.load('returnType', typeNames.join(' | ') + ` (nullable: ${nullable})`);
        } else {
          ice.load('returnType', typeNames.join(' | '));
        }

        ice.load('returnProperties', this._buildProperties(doc.properties, 'Return Properties:'));
      } else {
        ice.drop('returnParams');
      }

      // throws
      if (doc.throws) {
        ice.loop('throw', doc.throws, (i, exceptionDoc, ice)=>{
          ice.load('throwName', this._buildDocLinkHTML(exceptionDoc.types[0]));
          ice.load('throwDesc', exceptionDoc.description);
        });
      } else {
        ice.drop('throwWrap');
      }

      // fires
      if (doc.emits) {
        ice.loop('emit', doc.emits, (i, emitDoc, ice)=>{
          ice.load('emitName', this._buildDocLinkHTML(emitDoc.types[0]));
          ice.load('emitDesc', emitDoc.description);
        });
      } else {
        ice.drop('emitWrap');
      }

      // listens
      if (doc.listens) {
        ice.loop('listen', doc.listens, (i, listenDoc, ice)=>{
          ice.load('listenName', this._buildDocLinkHTML(listenDoc.types[0]));
          ice.load('listenDesc', listenDoc.description);
        });
      } else {
        ice.drop('listenWrap');
      }

      // example
      ice.into('example', doc.examples, (examples, ice)=>{
        ice.loop('exampleDoc', examples, (i, exampleDoc, ice)=>{
          let parsed = parseExample(exampleDoc);
          ice.text('exampleCode', parsed.body);
          ice.text('exampleCaption', parsed.caption);
        });
      });

      // tests
      ice.into('tests', doc._custom_tests, (tests, ice)=>{
        ice.loop('test', tests, (i, test, ice)=>{
          let testDoc = this._find({longname: test})[0];
          ice.load('test', this._buildFileDocLinkHTML(testDoc, testDoc.testFullDescription));
        });
      });
    });

    return ice;
  }

  /**
   * get output html page title. use ``title`` in {@link ESDocConfig}.
   * @param {DocObject} doc - target doc object.
   * @returns {string} page title.
   * @private
   */
  _getTitle(doc = '') {
    let name = doc.name || doc.toString();

    if (!name) {
      if (this._config.title) {
        return `${this._config.title} API Document`;
      } else {
        return 'API Document';
      }
    }

    if (this._config.title) {
      return `${name} | ${this._config.title} API Document`;
    } else {
      return `${name} | API Document`;
    }
  }

  /**
   * get base url html page. it is used html base tag.
   * @param {string} fileName - output file path.
   * @returns {string} base url.
   * @private
   */
  _getBaseUrl(fileName) {
    let baseUrl = '../'.repeat(fileName.split('/').length - 1);
    return baseUrl;
  }

  /**
   * gat url of output html page.
   * @param {DocObject} doc - target doc object.
   * @returns {string} url of output html. it is relative path from output root dir.
   * @private
   */
  _getURL(doc) {
    let inner = false;
    if (['variable', 'function', 'member', 'typedef', 'method', 'constructor', 'get', 'set'].includes(doc.kind)) {
      inner = true
    }

    if (inner) {
      let scope = doc.static ? 'static' : 'instance';
      let fileName = this._getOutputFileName(doc);
      return `${fileName}#${scope}-${doc.kind}-${doc.name}`;
    } else {
      let fileName = this._getOutputFileName(doc);
      return fileName;
    }
  }

  /**
   * get file name of output html page.
   * @param {DocObject} doc - target doc object.
   * @returns {string} file name.
   * @private
   */
  _getOutputFileName(doc) {
    switch (doc.kind) {
      case 'variable':
        return 'variable/index.html';
      case 'function':
        return 'function/index.html';
      case 'member': // fall
      case 'method': // fall
      case 'constructor': // fall
      case 'set': // fall
      case 'get': // fal
        let parentDoc = this._find({longname: doc.memberof})[0];
        return this._getOutputFileName(parentDoc);
      case 'external':
        return 'external/index.html';
      case 'typedef':
        return 'typedef/index.html';
      case 'class':
        return `class/${doc.longname}.html`;
      case 'file':
        return `file/${doc.longname}.html`;
      case 'testFile':
        return `test-file/${doc.longname}.html`;
      case 'testDescribe':
        return `test.html`;
      case 'testIt':
        return `test.html`;
      default:
        throw new Error('DocBuilder: can not resolve file name.');
    }
  }

  /**
   * build html link to file page.
   * @param {DocObject} doc - target doc object.
   * @param {string} text - link text.
   * @returns {string} html of link.
   * @private
   */
  _buildFileDocLinkHTML(doc, text = null) {
    if (!doc) return '';

    let fileDoc;
    if (doc.kind === 'file' || doc.kind === 'testFile') {
      fileDoc = doc;
    } else {
      let filePath = doc.longname.split('~')[0];
      fileDoc = this._find({kind: ['file', 'testFile'], longname: filePath})[0];
    }

    if (!fileDoc) return '';

    if (!text) text = fileDoc.name;

    if (doc.kind === 'file' || doc.kind === 'testFile') {
      return `<span><a href="${this._getURL(fileDoc)}">${text}</a></span>`;
    } else {
      return `<span><a href="${this._getURL(fileDoc)}#lineNumber${doc.lineNumber}">${text}</a></span>`;
    }
  }

  /**
   * build html link of type.
   * @param {string} typeName - type name(e.g. ``number[]``, ``Map<number, string>``)
   * @returns {string} html of link.
   * @private
   * @todo re-implement with parser combinator.
   */
  _buildTypeDocLinkHTML(typeName) {
    // e.g. number[]
    let matched = typeName.match(/^(.*?)\[\]$/);
    if (matched) {
      typeName = matched[1];
      return `<span>${this._buildDocLinkHTML(typeName, typeName)}<span>[]</span></span>`;
    }

    // e.g. function(a: number, b: string): boolean
    matched = typeName.match(/function *\((.*?)\)(.*)/);
    if (matched) {
      let functionLink = this._buildDocLinkHTML('function');
      if (!matched[1] && !matched[2]) return `<span>${functionLink}<span>()</span></span>`;

      let innerTypes = [];
      if (matched[1]) {
        // bad hack: Map.<string, boolean> => Map.<string\Z boolean>
        // bad hack: {a: string, b: boolean} => {a\Y string\Z b\Y boolean}
        let inner = matched[1]
          .replace(/<.*?>/g, (a)=> a.replace(/,/g, '\\Z'))
          .replace(/{.*?}/g, (a)=> a.replace(/,/g, '\\Z').replace(/:/g, '\\Y'));
        innerTypes = inner.split(',').map((v)=>{
          let tmp = v.split(':').map((v)=> v.trim());
          let paramName = tmp[0];
          let typeName = tmp[1].replace(/\\Z/g, ',').replace(/\\Y/g, ':');
          return `${paramName}: ${this._buildTypeDocLinkHTML(typeName)}`;
        });
      }

      let returnType = '';
      if (matched[2]) {
        let type = matched[2].split(':')[1];
        if (type) returnType = ': ' + this._buildTypeDocLinkHTML(type.trim());
      }

      return `<span>${functionLink}<span>(${innerTypes.join(', ')})</span>${returnType}</span>`;
    }

    // e.g. {a: number, b: string}
    matched = typeName.match(/^\{(.*?)\}$/);
    if (matched) {
      if (!matched[1]) return '{}';

      // bad hack: Map.<string, boolean> => Map.<string\Z boolean>
      // bad hack: {a: string, b: boolean} => {a\Y string\Z b\Y boolean}
      let inner = matched[1]
        .replace(/<.*?>/g, (a)=> a.replace(/,/g, '\\Z'))
        .replace(/{.*?}/g, (a)=> a.replace(/,/g, '\\Z').replace(/:/g, '\\Y'));
      let innerTypes = inner.split(',').map((v)=>{
        let tmp = v.split(':').map((v)=> v.trim());
        let paramName = tmp[0];
        let typeName = tmp[1].replace(/\\Z/g, ',').replace(/\\Y/g, ':');
        if (typeName.includes('|')) {
          typeName = typeName.replace(/^\(/, '').replace(/\)$/, '');
          let typeNames = typeName.split('|').map(v => v.trim());
          let html = [];
          for (let unionType of typeNames) {
            html.push(this._buildTypeDocLinkHTML(unionType));
          }
          return `${paramName}: ${html.join('|')}`;
        } else {
          return `${paramName}: ${this._buildTypeDocLinkHTML(typeName)}`;
        }
      });

      return `{${innerTypes.join(', ')}}`;
    }

    // e.g. Map<number, string>
    matched = typeName.match(/^(.*?)\.?<(.*?)>$/);
    if (matched) {
      let mainType = matched[1];
      // bad hack: Map.<string, boolean> => Map.<string\Z boolean>
      // bad hack: {a: string, b: boolean} => {a\Y string\Z b\Y boolean}
      let inner = matched[2]
        .replace(/<.*?>/g, (a)=> a.replace(/,/g, '\\Z'))
        .replace(/{.*?}/g, (a)=> a.replace(/,/g, '\\Z').replace(/:/g, '\\Y'));
      let innerTypes = inner.split(',').map((v) => {
        v = v.trim().replace(/\\Z/g, ',').replace(/\\Y/g, ':');
        return this._buildTypeDocLinkHTML(v);
      });

      let html = `${this._buildDocLinkHTML(mainType, mainType)}<${innerTypes.join(', ')}>`;
      return html;
    }

    if (typeName.indexOf('...') === 0) {
      typeName = typeName.replace('...', '');
      return '...' + this._buildDocLinkHTML(typeName);
    } else if (typeName.indexOf('?') === 0){
      typeName = typeName.replace('?', '');
      return '?' + this._buildDocLinkHTML(typeName);
    } else {
      return this._buildDocLinkHTML(typeName);
    }
  }

  /**
   * build html link to identifier.
   * @param {string} longname - link to this.
   * @param {string} [text] - link text. default is name property of doc object.
   * @param {boolean} [inner=false] - if true, use inner link.
   * @param {string} [kind] - specify target kind property.
   * @returns {string} html of link.
   * @private
   */
  _buildDocLinkHTML(longname, text = null, inner = false, kind = null) {
    if (!longname) return '';

    if (typeof longname !== 'string') throw new Error(JSON.stringify(longname));

    let doc = this._findByName(longname, kind)[0];

    if (!doc) {
      // if longname is HTML tag, not escape.
      if (longname.indexOf('<') === 0) {
        return `<span>${longname}</span>`;
      } else {
        return `<span>${escape(text || longname)}</span>`;
      }
    }

    if (doc.kind === 'external') {
      text = doc.name;
      return `<span><a href="${doc.externalLink}">${text}</a></span>`;
    } else {
      text = escape(text || doc.name);
      let url = this._getURL(doc, inner);
      if (url) {
        return `<span><a href="${url}">${text}</a></span>`;
      } else {
        return `<span>${text}</span>`;
      }
    }
  }

  /**
   * build html links to identifiers
   * @param {string[]} longnames - link to these.
   * @param {string} [text] - link text. default is name property of doc object.
   * @param {boolean} [inner=false] - if true, use inner link.
   * @param {string} [separator='\n'] - used link separator.
   * @returns {string} html links.
   * @private
   */
  _buildDocsLinkHTML(longnames, text = null, inner = false, separator = '\n') {
    if (!longnames) return '';
    if (!longnames.length) return '';

    let links = [];
    for (var longname of longnames) {
      if (!longname) continue;
      let link = this._buildDocLinkHTML(longname, text, inner);
      links.push(`<li>${link}</li>`);
    }

    if (!links.length) return '';

    return `<ul>${links.join(separator)}</ul>`;
  }

  /**
   * build identifier signature html.
   * @param {DocObject} doc - target doc object.
   * @returns {string} signature html.
   * @private
   */
  _buildSignatureHTML(doc) {
    // call signature
    let callSignatures = [];
    if (doc.params) {
      for (let param of doc.params) {
        let paramName = param.name;
        if (paramName.indexOf('.') !== -1) continue;

        let types = [];
        for (let typeName of param.types) {
          types.push(this._buildTypeDocLinkHTML(typeName));
        }

        callSignatures.push(`${paramName}: ${types.join(' | ')}`);
      }
    }

    // return signature
    let returnSignatures = [];
    if (doc.return) {
      for (let typeName of doc.return.types) {
        returnSignatures.push(this._buildTypeDocLinkHTML(typeName));
      }
    }

    // type signature
    let typeSignatures = [];
    if (doc.type) {
      for (let typeName of doc.type.types) {
        typeSignatures.push(this._buildTypeDocLinkHTML(typeName));
      }
    }

    // callback is not need type. because type is always function.
    if (doc.kind === 'function') {
      typeSignatures = [];
    }

    let html = '';
    if (callSignatures.length) {
      html = `(${callSignatures.join(', ')})`;
    } else if (['function', 'method'].includes(doc.kind)) {
      html = '()';
    }
    if (returnSignatures.length) html = `${html}: ${returnSignatures.join(' | ')}`;
    if (typeSignatures.length) html = `${html}: ${typeSignatures.join(' | ')}`;

    return html;
  }

  /**
   * build properties output.
   * @param {ParsedParam[]} [properties=[]] - properties in doc object.
   * @param {string} title - output title.
   * @return {IceCap} built properties output.
   * @private
   */
  _buildProperties(properties = [], title = 'Properties:') {
    let ice = new IceCap(this._readTemplate('properties.html'));

    ice.text('title', title);

    ice.loop('property', properties, (i, prop, ice)=>{
      ice.autoDrop = false;
      ice.attr('property', 'data-depth', prop.name.split('.').length - 1);
      ice.text('name', prop.name);
      ice.attr('name', 'data-depth', prop.name.split('.').length - 1);
      ice.load('description', prop.description);

      let typeNames = [];
      for (var typeName of prop.types) {
        typeNames.push(this._buildTypeDocLinkHTML(typeName));
      }
      ice.load('type', typeNames.join(' | '));

      // appendix
      let appendix = [];
      if (prop.optional) {
        appendix.push('<li>optional</li>');
      }
      if ('defaultValue' in prop) {
        appendix.push(`<li>default: ${prop.defaultValue}</li>`);
      }
      if (typeof prop.nullable === 'boolean') {
        appendix.push(`<li>nullable: ${prop.nullable}</li>`);
      }
      if (appendix.length) {
        ice.load('appendix', `<ul>${appendix.join('\n')}</ul>`);
      } else {
        ice.text('appendix', '');
      }
    });

    if (!properties || properties.length === 0) {
      ice.drop('properties');
    }

    return ice;
  }

  /**
   * build deprecated html.
   * @param {DocObject} doc - target doc object.
   * @returns {string} if doc is not deprecated, returns empty.
   * @private
   */
  _buildDeprecatedHTML(doc) {
    if (doc.deprecated) {
      let deprecated = [`this ${doc.kind} was deprecated.`];
      if (typeof doc.deprecated === 'string') deprecated.push(doc.deprecated);
      return deprecated.join(' ');
    } else {
      return '';
    }
  }

  /**
   * build experimental html.
   * @param {DocObject} doc - target doc object.
   * @returns {string} if doc is not experimental, returns empty.
   * @private
   */
  _buildExperimentalHTML(doc) {
    if (doc.experimental) {
      let experimental = [`this ${doc.kind} is experimental.`];
      if (typeof doc.experimental === 'string') experimental.push(doc.experimental);
      return experimental.join(' ');
    } else {
      return '';
    }
  }

  /**
   * build method of ancestor class link html.
   * @param {DocObject} doc - target doc object.
   * @returns {string} html link. if doc does not override ancestor method, returns empty.
   * @private
   */
  _buildOverrideMethod(doc) {
    let parentDoc = this._findByName(doc.memberof)[0];
    if (!parentDoc) return '';
    if (!parentDoc._custom_extends_chains) return;

    let chains = [...parentDoc._custom_extends_chains].reverse();
    for (let longname of chains) {
      let superClassDoc = this._findByName(longname)[0];
      if (!superClassDoc) continue;

      let superMethodDoc = this._find({name: doc.name, memberof: superClassDoc.longname})[0];
      if (!superMethodDoc) continue;

      return this._buildDocLinkHTML(superMethodDoc.longname, `${superClassDoc.name}#${superMethodDoc.name}`, true);
    }
  }

  /**
   * build coverage html.
   * @param {CoverageObject} coverageObj - target coverage object.
   * @returns {string} html of coverage badge.
   * @private
   * @deprecated
   */
  _buildCoverageHTML(coverageObj) {
    let coverage = Math.floor(100 * coverageObj.actualCount / coverageObj.expectCount);
    let colorClass;
    if (coverage < 50) {
      colorClass = 'esdoc-coverage-low';
    } else if (coverage < 90) {
      colorClass = 'esdoc-coverage-middle';
    } else {
      colorClass = 'esdoc-coverage-high';
    }

    let html = `<a href="https://esdoc.org" class="esdoc-coverage-wrap">
    <span class="esdoc-coverage-label">document</span><span class="esdoc-coverage-ratio ${colorClass}">${coverage}%</span>
    </a>`;

    return html;
  }

  //_buildAuthorHTML(doc, separator = '\n') {
  //  if (!doc.author) return '';
  //
  //  var html = [];
  //  for (var author of doc.author) {
  //    var matched = author.match(/(.*?) *<(.*?)>/);
  //    if (matched) {
  //      var name = matched[1];
  //      var link = matched[2];
  //      if (link.indexOf('http') === 0) {
  //        html.push(`<li><a href="${link}">${name}</a></li>`)
  //      } else {
  //        html.push(`<li><a href="mailto:${link}">${name}</a></li>`)
  //      }
  //    } else {
  //      html.push(`<li>${author}</li>`)
  //    }
  //  }
  //
  //  return `<ul>${html.join(separator)}</ul>`;
  //}
}