src/index.js
import fs from 'fs';
import path from 'path';
import request from 'request';
import logger from 'log-util';
/**
* ESDocUploader, connects with the [ESDoc hosting service](https://doc.esdoc.org/) API in order
* to generage the documentation for your project.
* @version 1.0.0
*/
export default class ESDocUploader {
/**
* Create a new instance of the uploader.
* @param {String} [url=null] - This is the GitHub repository url. The required format its
* `[email protected]:[author]/[repository].git`. You can also
* ignore it and it will automatically search for it on your
* `package.json`.
* @public
*/
constructor(url = null) {
/**
* A list of pre defined messages that the class will log.
* @type {Object}
* @private
* @ignore
*/
this._messages = {
constructor: 'The repository url is invalid. ' +
'There is likely additional logging output above',
invalidUrl: 'The repository url is invalid. You can\'t upload anything',
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:',
};
if (url === null) {
url = this._retrieveUrlFromPackage();
} else {
url = this._validateUrl(url);
}
/**
* The repository url. It can be null if the one provided is not valid or if there isn't
* one on the `package.json`.
* @type {string|null}
* @private
* @ignore
*/
this.url = url;
/**
* A flag to know if the class it's currently uploading something.
* @type {Boolean}
* @private
* @ignore
*/
this._uploading = false;
/**
* A small dictionary used to store information relative to the ESDoc API, like it's
* main domain or the path to create a new doc.
* @type {Object}
* @private
* @ignore
*/
this._api = {
domain: 'https://doc.esdoc.org',
create: '/api/create',
};
/**
* The name of the file where the class it's going to check if the docs were uploaded.
* @type {String}
* @private
* @ignore
*/
this._finishFile = '/.finish.json';
/**
* The amount of time the class will wait between checks to see if the docs site was
* generated.
* @type {Number}
* @private
* @ignore
*/
this._intervalTime = 4000;
/**
* After the first request, this is where the returned path for the docs on the server
* will be stored.
* @type {String}
* @private
* @ignore
*/
this._path = '';
/**
* A callback that will be executed after confirmation that the docs were generated.
* @type {Function}
* @private
* @ignore
*/
this._callback = null;
/**
* The text that will show up on the terminal.
* @type {String}
* @private
* @ignore
*/
this._indicatorText = 'Uploading';
/**
* The amout of time in which the indicator will be updated.
* @type {Number}
* @private
* @ignore
*/
this._indicatorInterval = 1000;
/**
* A utility counter to know how many dos will be added to the indicator
* @type {Number}
* @private
* @ignore
*/
this._indicatorCounter = -1;
/**
* After this many iterations, the dots will start to be removed instead of added. When the
* counter hits 0, it will start adding again, until it hits this limit.
* @type {Number}
* @private
* @ignore
*/
this._indicatorLimit = 3;
/**
* A flag to know if the indicator it's currently adding dots or removing them.
* @type {Boolean}
* @private
* @ignore
*/
this._indicatorIncrease = true;
/**
* If there's no url, log the error message.
*/
if (this.url === null) {
this._logError('constructor');
}
}
/**
* After the class is istantiated, this method can be used to check if the url is valid and
* if the method `upload` can be called
* @public
*/
canUpload() {
return this.url !== null;
}
/**
* Upload your documentation to the ESDoc API.
* @param {Function} [callback=() => {}] - An optional callback to be executed after
* everthing is ready.
* @public
*/
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();
request.post({
url: this._getAPIUrl('create'),
body: {gitUrl: this.url},
json: true,
}, ((err, httpResponse, body) => {
if (err) {
this._logError(err);
} else {
let response = body;
if (typeof response === 'string') {
response = JSON.parse(response);
}
if (!response.success) {
this._logError(response.message || 'unexpected');
} else {
this._setAPIUrl('path', response.path);
this._setAPIUrl('status', response.path + this._finishFile);
this._startAsking();
}
}
}).bind(this));
}
}
/**
* Tries to retrieve the repository url from your `pacakge.json`.
* @return {String} The repository url that was on your `package.json`.
* @private
* @ignore
*/
_retrieveUrlFromPackage() {
const packagePath = path.resolve('./package.json');
const packageContents = fs.readFileSync(packagePath, 'utf-8');
let result = null;
if (!packageContents) {
this._logError('noPackage');
} else {
const property = JSON.parse(packageContents).repository;
if (!property) {
this._logError('noRepository');
}else if (typeof property === 'string') {
const urlParts = property.split('/');
if (urlParts.length !== 2) {
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 - 2];
const repository = urlParts[urlParts.length - 1];
result = this._buildUrl(author, repository);
}
}
}
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} The new url, on the required format for ESDoc.
* @private
* @ignore
*/
_buildUrl(author, repository) {
if (repository.indexOf('.git') > -1) {
repository = repository.substr(0, repository.length - 4);
}
return '[email protected]:' + author + '/' + repository + '.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|null} If the url it's valid, it will return it, otherwise, itw will
* return null.
* @private
* @ignore
*/
_validateUrl(url) {
let result = null;
if (url.match(/^git@github\.com:[\w\d._-]+\/[\w\d._-]+\.git$/)) {
result = url;
}
return result;
}
/**
* This method is called after the initial request to the API, and tells the class to check
* every X seconds to see if the documentation was uploaded.
* @private
* @ignore
*/
_startAsking() {
setTimeout(this._ask.bind(this), this._intervalTime);
}
/**
* It makes a request to check if the documentation was uploaded or not.
* @private
* @ignore
*/
_ask() {
request(this._getAPIUrl('status'), ((err, httpResponse, body) => {
if (err || body.indexOf('<html>') > -1) {
this._startAsking();
} else {
const response = JSON.parse(body);
if (!response.success) {
this._logError(response.message || 'unexpected');
} else {
this._finish();
}
}
}).bind(this));
}
/**
* This method is called after it's confirmed that the documentation was successfully uploaded,
* and it stops teh indicator, logs a mesage with the url for the documetation and invokes the
* callback set in the `upload()` method.
* @private
* @ignore
*/
_finish() {
this._uploading = false;
this._stopIndicator();
const docUrl = this._getAPIUrl('path');
logger.debug(this._messages.success + ' ' + docUrl);
this._callback(true, docUrl);
}
/**
* Returns a url for the ESDoc API.
* @param {String} type - The type of url you need. This is parameter it's the key for the
* `_api` dictionary.
* @return {String} It will return the API domain and the value in the `_api` dictionary for
* given type.
* @private
* @ignore
*/
_getAPIUrl(type) {
return this._api.domain + this._api[type];
}
/**
* Set a new type of urlf or the ESDoc API. For example, the first request will return a
* relative path for the documentation, this class will use this method to save this path
* so it can be later be retrieved using `_getAPIUrl` and it wil already have the API main
* domain.
* @param {String} type - An identifier for your url.
* @param {String} url - The relative url you want to save.
* @private
* @ignore
*/
_setAPIUrl(type, url) {
this._api[type] = url;
}
/**
* Logs an eror message to the terminal.
* @param {String|Error} error - This can be the message you want to log, a key for the
* `_messages` dictionary or an `Error` object.
* @private
* @ignore
*/
_logError(error) {
if (typeof error === 'string') {
if (this._messages[error]) {
error = this._messages[error];
}
} else {
error = error.message;
}
logger.error(error);
this._stopIndicator(false);
if (this._callback) {
this._callback(false);
}
}
/**
* Starts showing the progress indicator on the terminal.
* @private
* @ignore
*/
_startIndicator() {
this._indicatorInterval = setInterval(this._runIndicator.bind(this), 500);
}
/**
* The actual method that shows the progress indicator on the terminal.
* @private
* @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.
* @private
* @ignore
*/
_stopIndicator() {
clearInterval(this._indicatorInterval);
this._restartLine();
}
/**
* Removes everything on the current terminal line and sets the cursor to the initial
* position.
* @private
* @ignore
*/
_restartLine() {
process.stdout.clearLine();
process.stdout.cursorTo(0);
}
/**
* Writes a message in the terminal.
* @param {String} message - The text to write.
* @private
* @ignore
*/
_print(message) {
process.stdout.write(message);
}
}