Home Reference Source

src/index.js

const https = require('https');
const fs = require('fs');
const path = require('path');
/**
 * @typedef {Function} UploadCallback
 * @param {Boolean} success Whether the documentation was uploaded or not.
 * @param {?String} url     The url for the documentation.
 */

/**
 * @typedef {Function} RequestCallback
 * @param {?Error} error    In case the request fails.
 * @param {String} response The request response.
 * @param {Number} status   The response status code.
 * @ignore
 */

/**
* ESDocUploader, connects with the [ESDoc hosting service](https://doc.esdoc.org/) API in order
* to generage the documentation for your project.
*/
class ESDocUploader {
  /**
   * @param {?String} [url=null] This is the GitHub repository url. The required format its
   *                             `git[at]github.com:[author]/[repository].git`. You can also
   *                             ignore it and it will automatically search for it on your
   *                             `package.json`.
   */
  constructor(url = null) {
    /**
     * A list of pre defined messages that the class will log.
     * @type {Object}
     * @protected
     * @ignore
     */
    this._messages = {
      invalidUrl: 'The repository url is invalid',
      invalidPackageUrl: 'The repository url is invalid. ' +
        'There is likely additional logging output above',
      uploading: 'The documentation is already being uploaded',
      unexpected: 'Unexpected error, please try again',
      noPackage: 'There\'s no package.json in this directory',
      noRepository: 'There\'s no repository information in the package.json',
      invalidFormat: 'The repository from the package.json it\'s not valid. ' +
      'Expected format "[author]/[repository]"',
      onlyGithub: 'ESDoc only supports Github repositories',
      success: 'The documentation was successfully uploaded:',
    };
    /**
     * The repository url. It can be `null` if the one provided via the parameter is invalid or
     * if a valid one can't be retrieved from the `package.json`.
     * @type {?String}
     * @protected
     * @ignore
     */
    this._url = url === null ? this._retrieveUrlFromPackage() : this._validateUrl(url);
    /**
     * A flag to know if the class it's currently uploading the documentation or not.
     * @type {Boolean}
     * @protected
     * @ignore
     */
    this._uploading = false;
    /**
     * A small dictionary used to store information relative to the ESDoc API, like it's
     * main hostname and the path to create a new documentation.
     * When a new documentation is created, this object will be updated with the path
     * to check if the documentation is ready.
     * @type {Object}
     * @protected
     * @ignore
     */
    this._api = {
      host: 'doc.esdoc.org',
      create: '/api/create',
    };
    /**
     * The name of the file where the class it's going to check if the docs were uploaded. The
     * complete path is created with the information from the response the class gets when a
     * new documentation is created.
     * @type {String}
     * @protected
     * @ignore
     */
    this._finishFile = '/.finish.json';
    /**
     * The interval time the class will use in order to check if an uploaded documentation
     * is available or not.
     * @type {Number}
     * @protected
     * @ignore
     */
    this._intervalTime = 4000;
    /**
     * A callback that will be executed after getting a confirmation that the documentation
     * was successfully updated. It's value is set using the `upload` method.
     * @type {?UploadCallback}
     * @protected
     * @ignore
     */
    this._callback = null;
    /**
     * The text that will show up on the console.
     * @type {String}
     * @protected
     * @ignore
     */
    this._indicatorText = 'Uploading';
    /**
     * The amout of time in which the indicator will be updated.
     * @type {Number}
     * @protected
     * @ignore
     */
    this._indicatorInterval = 1000;
    /**
     * A utility counter to know how many dots will be added to the indicator.
     * @type {Number}
     * @protected
     * @ignore
     */
    this._indicatorCounter = -1;
    /**
     * After this many iterations, the dots in the indicator will start to be removed instead
     * of being added. When the counter hits 0, it will start adding again, until it
     * hits this limit.
     * @type {Number}
     * @protected
     * @ignore
     */
    this._indicatorLimit = 3;
    /**
     * A flag to know if the indicator it's currently adding dots or removing them.
     * @type {Boolean}
     * @protected
     * @ignore
     */
    this._indicatorIncrease = true;
    /**
     * @ignore
     */
    this._ask = this._ask.bind(this);
    /**
     * @ignore
     */
    this._runIndicator = this._runIndicator.bind(this);
  }
  /**
   * Checks whether the repository is valid and the class can start uploading the documentation.
   * @return {Boolean}
   */
  canUpload() {
    return !!this._url;
  }
  /**
   * Upload your documentation to the ESDoc API.
   * @param {UploadCallback} callback An optional callback to be executed after everthing
   *                                  is ready.
   */
  upload(callback) {
    if (this._url === null) {
      this._callback = callback;
      this._logError('invalidUrl');
    } else if (this._uploading) {
      this._logError('uploading');
    } else {
      this._callback = callback;
      this._uploading = true;
      this._startIndicator();
      this._postRequest(
        'create',
        { gitUrl: this._url },
        (error, response) => {
          if (error) {
            this._logError(error);
          } else {
            const useResponse = JSON.parse(response);
            if (useResponse.success) {
              this._setAPIPath('path', useResponse.path);
              this._setAPIPath(
                'status',
                `${useResponse.path}${this._finishFile}`
              );
              this._startAsking();
            } else {
              this._logError(useResponse.message || 'unexpected');
            }
          }
        }
      );
    }
  }
  /**
   * The repository url the class will send to the ESDoc API.
   * @type {?String}
   */
  get url() {
    return this._url;
  }
  /**
   * Tries to retrieve the repository url from the project's `pacakge.json`.
   * @return {String}
   * @protected
   * @ignore
   */
  _retrieveUrlFromPackage() {
    const packagePath = path.resolve('./package.json');
    let packageContents;
    try {
      packageContents = fs.readFileSync(packagePath, 'utf-8');
    } catch (ignore) {
      // This is ignored because we already have the error going out if there's no package.
    }
    let result = null;
    if (packageContents) {
      const authorAndRepoParts = 2;
      const property = JSON.parse(packageContents).repository;
      if (!property) {
        this._logError('noRepository');
      } else if (typeof property === 'string') {
        const urlParts = property.split('/');
        if (urlParts.length !== authorAndRepoParts) {
          this._logError('invalidFormat');
        } else {
          result = this._buildUrl(urlParts[0], urlParts[1]);
        }
      } else if (property.type !== 'git' || !property.url.match(/github/)) {
        this._logError('onlyGithub');
      } else {
        const urlParts = property.url.split('/');
        const author = urlParts[urlParts.length - authorAndRepoParts];
        const repository = urlParts[urlParts.length - 1];
        result = this._buildUrl(author, repository);
      }
    } else {
      this._logError('noPackage');
    }

    if (result === null) {
      this._logError('invalidPackageUrl');
    }

    return result;
  }
  /**
   * Generates a new url with the required format to use with the ESDoc API.
   * @param {String} author     The GitHub username.
   * @param {String} repository The repository name.
   * @return {String}
   * @protected
   * @ignore
   */
  _buildUrl(author, repository) {
    const extension = '.git';
    const useRepository = repository.includes(extension) ?
      repository.substr(0, repository.length - extension.length) :
      repository;

    return `git@github.com:${author}/${useRepository}.git`;
  }
  /**
   * Validates a given url to see if it has the required format by the ESDoc API.
   * @param {String} url - The url to validate.
   * @return {?String} If the url it's valid, it will return it, otherwise, it will
   *                   return `null`.
   * @protected
   * @ignore
   */
  _validateUrl(url) {
    let result = null;
    if (url.match(/^git@github\.com:[\w\d._-]+\/[\w\d._-]+\.git$/)) {
      result = url;
    } else {
      this._logError('invalidUrl');
    }

    return result;
  }
  /**
   * This method is called after the initial request to the API, and tells the class to check
   * every X milliseconds to see if the documentation was uploaded.
   * @protected
   * @ignore
   */
  _startAsking() {
    setTimeout(this._ask, this._intervalTime);
  }
  /**
   * It makes a request to check if the documentation was uploaded or not. If is not ready, it
   * will call `_startAsking` to setup a new check; otherwise, it will invoke the callback sent
   * to `upload`.
   * @protected
   * @ignore
   */
  _ask() {
    this._getRequest('status', (error, response) => {
      if (error || response.includes('<html>')) {
        this._startAsking();
      } else {
        const useResponse = JSON.parse(response);
        if (useResponse.success) {
          this._finish();
        } else {
          this._logError(useResponse.message || 'unexpected');
        }
      }
    });
  }
  /**
   * This method is called after it's confirmed that the documentation was successfully uploaded,
   * it stops the indicator, logs a message with the url for the documetation and invokes the
   * callback set in the `upload()` method.
   * @protected
   * @ignore
   */
  _finish() {
    this._uploading = false;
    this._stopIndicator();
    const url = this._getAPIUrl('path');
    // eslint-disable-next-line no-console
    console.log(
      '\x1b[30m[%s] \x1b[32m%s\x1b[0m',
      new Date(),
      `${this._messages.success} ${url}`
    );
    this._callback(true, url);
  }
  /**
   * Returns a complete url for the ESDoc API.
   * @param  {String} apiPath The reference name for the path the request is for,
   *                          inside the `_api` dictionary.
   * @protected
   * @ignore
   */
  _getAPIUrl(apiPath) {
    const usePath = this._api[apiPath];
    return `https://${this._api.host}${usePath}`;
  }
  /**
   * Makes a POST request to the API.
   * @param {String}          apiPath   The reference name for the path the request is for,
   *                                    inside the `_api` dictionary.
   * @param {Object}          body      The body of the request.
   * @param {RequestCallback} callback  The callback to be invoked when the request is finished.
   * @protected
   * @ignore
   */
  _postRequest(apiPath, body, callback) {
    const data = JSON.stringify(body);
    const options = {
      hostname: this._api.host,
      path: this._api[apiPath],
      port: 443,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': data.length,
      },
    };
    const req = this._createAPIRequest(options, callback);
    req.write(data);
    req.end();
  }
  /**
   * Makes a GET request to the API.
   * @param {String}          apiPath   The reference name for the path the request is for,
   *                                    inside the `_api` dictionary.
   * @param {RequestCallback} callback  The callback to be invoked when the request is finished.
   * @protected
   * @ignore
   */
  _getRequest(apiPath, callback) {
    const options = {
      hostname: this._api.host,
      path: this._api[apiPath],
      port: 443,
      method: 'GET',
    };
    const req = this._createAPIRequest(options, callback);
    req.end();
  }
  /**
   * A wrapper on top of `https.request` that allows the class to make requests, setup the
   * listeners and resolve everything on a single callback.
   * @param {Object}           reqOptions  The options for `https.request`.
   * @param {RequestCallback}  callback    The callback to be invoked when the request is
   *                                       finished.
   * @protected
   * @ignore
   */
  _createAPIRequest(reqOptions, callback) {
    return https.request(reqOptions, (res) => {
      const { statusCode } = res;
      const chunks = [];
      let errored = false;
      res.on('data', (chunk) => {
        chunks.push(chunk);
      });
      res.on('error', (error) => {
        errored = true;
        callback(error, null, statusCode);
      });
      res.on('end', () => {
        if (!errored) {
          const response = Buffer.concat(chunks).toString();
          const badRequest = 400;
          if (statusCode >= badRequest) {
            callback(
              new Error(`The API responded with a ${statusCode}`),
              response,
              statusCode
            );
          } else {
            callback(null, response, statusCode);
          }
        }
      });
    });
  }
  /**
   * Sets a new path reference to be used with the ESDoc API.
   * After triggering the upload, this will be used to store the path the API uses so the class
   * can check if the documentation is available.
   * @param {String} name    A reference identifier for the path.
   * @param {String} apiPath The relative path you want to save.
   * @protected
   * @ignore
   */
  _setAPIPath(name, apiPath) {
    this._api[name] = apiPath;
  }
  /**
   * Logs an eror message to the terminal and, if `upload` was ever call, it invokes the callback
   * informing that the operation wasn't successful.
   * @param {String|Error} error The message to log, a key for the `_messages` dictionary or
   *                             an `Error` object.
   * @protected
   * @ignore
   */
  _logError(error) {
    let useError;
    if (typeof error === 'string') {
      useError = this._messages[error] || error;
    } else {
      useError = error.message;
    }
    // eslint-disable-next-line no-console
    console.log('\x1b[30m[%s] \x1b[31m%s\x1b[0m', new Date(), useError);
    this._stopIndicator(false);
    if (this._callback) {
      this._callback(false);
    }
  }
  /**
   * Starts showing the progress indicator on the terminal.
   * @protected
   * @ignore
   */
  _startIndicator() {
    const indicatorIntervalTime = 500;
    this._indicatorInterval = setInterval(
      this._runIndicator,
      indicatorIntervalTime
    );
  }
  /**
   * The actual method that shows the progress indicator on the terminal.
   * @protected
   * @ignore
   */
  _runIndicator() {
    let text = this._indicatorText;
    if (this._indicatorIncrease) {
      this._indicatorCounter++;
      if (this._indicatorCounter === this._indicatorLimit) {
        this._indicatorIncrease = false;
      }
    } else {
      this._indicatorCounter--;
      if (this._indicatorCounter === 0) {
        this._indicatorIncrease = true;
      }
    }

    for (let i = 0; i < this._indicatorCounter; i++) {
      text += '.';
    }

    this._restartLine();
    this._print(text);
  }
  /**
   * Removes the progress indicator from the terminal.
   * @protected
   * @ignore
   */
  _stopIndicator() {
    clearInterval(this._indicatorInterval);
    this._restartLine();
  }
  /**
   * Removes everything on the current terminal line and sets the cursor to the initial
   * position.
   * @protected
   * @ignore
   */
  _restartLine() {
    process.stdout.clearLine();
    process.stdout.cursorTo(0);
  }
  /**
   * Writes a message in the terminal.
   * @param {String} message - The text to write.
   * @protected
   * @ignore
   */
  _print(message) {
    process.stdout.write(message);
  }
}

module.exports = ESDocUploader;