src/plugins/stats/index.js
const extend = require('extend');
const fs = require('fs-extra');
const prettysize = require('prettysize');
const colors = require('colors/safe');
const { Logger } = require('wootils/node/logger');
/**
* This is a Rollup plugin that shows stats on the files generated and/or copied. The way this
* plugin works is kind of different: Once instantiated, it has an `add` metho for registering
* stats entries and that can be sent to other plugins; and at the same time, it has a `log` method
* that you would add as a plugin and it would show the entries.
*
* @example
* const stats = new ProjextRollupStatsPlugin();
* stats.add(...);
* stats.add(...);
* ...
* module.exports = {
* plugins: [
* commonjs(),
* resolve(),
* ...,
* stats.log(),
* ],
* };
*/
class ProjextRollupStatsPlugin {
/**
* @param {ProjextRollupStatsPluginOptions} [options={}]
* The options to customize the plugin behaviour.
* @param {string} [name='projext-rollup-plugin-node-stats']
* The name of the plugin's instance.
*/
constructor(options = {}, name = 'projext-rollup-plugin-stats') {
/**
* The plugin options.
* @type {ProjextRollupStatsPluginOptions}
* @access protected
* @ignore
*/
this._options = extend(
true,
{
path: '',
},
options
);
/**
* The name of the plugin's instance.
* @type {string}
*/
this.name = name;
/**
* The list of entries added to the plugin's instance.
* @type {Array}
* @access protected
* @ignore
*/
this._entries = [];
/**
* The dictionary with the headers of the report table the plugin will show when it logs
* the entries.
* @type {Object}
* @access protected
* @ignore
*/
this._reportHeaders = {
file: 'Asset',
size: 'Size',
plugin: 'Plugin',
};
/**
* A custom {@link Logger} to log the report table. It can be set on the options of the `log`
* method.
* @type {?Logger}
* @access protected
* @ignore
*/
this._logger = null;
/**
* @ignore
*/
this.add = this.add.bind(this);
}
/**
* Gets the plugin options
* @return {ProjextRollupStatsPluginOptions}
*/
getOptions() {
return this._options;
}
/**
* Generates a _"sub plugin"_ to add to the plugins queue and that will reset the entries list.
* The reason this exists is because when Rollup is on _"watch mode"_, the plugins can run more
* than once, and if the queue is not reseted, the report table will show the entries for ALL
* the times the plugin ran.
* @return {ProjextRollupStatsPluginReset}
*/
reset() {
return {
intro: () => {
this._entries = [];
},
};
}
/**
* Generates a _"sub plugin"_ to add to the plugins queue and that will take care of logging
* all the added entries on a report table.
* @param {ProjextRollupStatsPluginLogOptions} [options={}] Custom options for the _"sub plugin"_.
* @return {ProjextRollupStatsPluginLog}
*/
log(options = {}) {
// Merge the default and custom options.
const newOptions = extend(
true,
{
extraEntries: [],
logger: null,
afterLog: null,
},
options
);
// Validate the logger.
const logger = this._validateLogger(newOptions.logger);
// If there was a valid logger, assign it to the local property and remove it form the options.
if (logger) {
this._logger = logger;
delete newOptions.logger;
}
// Return the _"sub plugin"_.
return {
writeBundle: () => {
/**
* Add any extra entry specified on the options. The reason they're being added here
* instead of the parent scope it's because the `reset` _"sub plugin"_ may remove them
* if they are added out of the plugins cycle.
*/
newOptions.extraEntries.forEach((entry) => {
this.add(entry.plugin, entry.filepath);
});
// Log the report table.
return this._logStats()
.then(() => {
if (newOptions.afterLog) {
newOptions.afterLog();
}
});
},
};
}
/**
* Adds a new stats entry.
* @param {string|Promise} plugin This can be either the name of the plugin generating the
* entry, or, if the spot should be saved but the actual
* entry is involved on an async task, a promise the plugin
* will wait for. The promise should be resolved with an
* object with the keys `plugin` and `filepath`.
* @param {?string} [filepath] The path for the file that was generatedcopied. This is
* not required if `plugin` is a promise.
* @param {?number} [index=null] If this value is specified, instead of adding the entry
* to the list, it will be set at the given index. This is
* used internally by the plugin after resolving promise
* based entries, instead of adding the resolved value, the
* plugin replaces the entry that had the promise with the
* resolved information.
*/
add(plugin, filepath, index = null) {
// Build the entry object after validating if `plugin` is a promise.
const entry = this._isPromise(plugin) ? plugin : {
plugin,
filepath,
};
// If index was defined...
if (typeof index === 'number') {
// Replace the entry at the given index.
this._entries[index] = entry;
} else {
// Push the entry at the end of the list.
this._entries.push(entry);
}
}
/**
* Validates if an object can be used as a logger. The object is only allowed if it's an instance
* of {@link Logger} or it has a `log` method.
* @param {Logger|Object} logger The logger to validate.
* @return {Logger|Object}
* @throws {Error} If the object is not an instance of {@link Logger} and it doesn't have a `log`
* method.
* @access protected
* @ignore
*/
_validateLogger(logger) {
let result = null;
/**
* If `logger` is _"truthy"_ and it's either an instance of {@link Logger} or has a `log`
* method...
*/
if (
logger &&
(
logger instanceof Logger ||
typeof logger.log === 'function'
)
) {
// ...set it to be returned as a valid logger.
result = logger;
} else if (logger) {
// ...but if there's a `logger` but it doesn't have a valid interface, throw an error.
throw new Error(`${this.name}: The logger must be an instance of wootils' Logger class`);
}
return result;
}
/**
* This is the method in charge of logging the report table.
* @return {Promise<undefined,Error>}
* @access protected
* @ignore
*/
_logStats() {
// Resolve any pending entry.
return this._resolveEntries()
.then(() => {
// Sort the entries list.
let newEntries = this._sortEntries(this._entries);
// Normalize the entries paths and obtain the files size.
newEntries = this._formatEntries(newEntries);
// Generate the report table.
const stats = this._generateStats(newEntries);
// If a valid `logger` was sent, use it to log the table, otherwise use the `console`.
if (this._logger) {
this._logger.log(stats);
} else {
// eslint-disable-next-line no-console
console.log(stats);
}
});
}
/**
* Resolves any pending entries that were added as promises.
* @return {Promise<undefined,Error>}
* @access protected
* @ignore
*/
_resolveEntries() {
const promises = [];
// Loop all the entries and pick the promises and their indexes.
this._entries.forEach((entry, index) => {
if (this._isPromise(entry)) {
promises.push({
entry,
index,
});
}
});
// Define the variable to return.
let result;
// If there are promises to solve...
if (promises.length) {
// ...set to return a `Promise.all` of all of them.
result = Promise.all(promises.map((entryInfo) => this._resolveEntry(entryInfo)));
} else {
// ...otherwise, return an already resolved promise.
result = Promise.resolve();
}
return result;
}
/**
* Resolves a single promise based entry.
* @param {Object} entryInfo The information of the entry to be resolved.
* @param {Promise<Object,Error>} entryInfo.entry The promise to resolve.
* @return {Promise<undefined,Error>}
* @access protected
* @ignore
*/
_resolveEntry(entryInfo) {
// Wait for the promise to be resolved.
return entryInfo
.entry
.then((newEntry) => {
// Replace the entry with the obtained information.
this.add(newEntry.plugin, newEntry.filepath, entryInfo.index);
});
}
/**
* Checks whether an object is a promise or not. It should be of type `Object` and have a `then`
* method.
* @param {*} obj The object to validate.
* @return {boolean}
* @access protected
* @ignore
*/
_isPromise(obj) {
return typeof obj === 'object' && obj.then && typeof obj.then === 'function';
}
/**
* Sorts an entries list.
* @param {Array} entries The entries list.
* @return {Array}
* @access protected
* @ignore
*/
_sortEntries(entries) {
const entriesByFile = {};
/**
* Loop all the entries, put them on a dictionary using the file path as key, and build a
* list of file paths.
*/
return entries.map((entry) => {
entriesByFile[entry.filepath] = entry;
return entry.filepath;
})
// Sort the list of file paths.
.sort()
/**
* Build a new array by looping the sorted entries and retrieving the information from
* the dictionary.
*/
.map((filepath) => entriesByFile[filepath]);
}
/**
* Formats a list of entries by normalizing their paths, obtaining their size and making it
* human readable.
* @param {Array} entries The list of entries.
* @return {Array}
* @access protected
* @ignore
*/
_formatEntries(entries) {
// Loop all the entries.
return entries
// Filter all entries which files don't exist.
.filter((entry) => fs.pathExistsSync(entry.filepath))
// Loop all the filtered entries.
.map((entry) => {
const { plugin, filepath } = entry;
// Normalize the file path.
const file = this._resolveFilepath(filepath);
// Normalize the file size.
const size = this._getPrettyFilesize(filepath);
// Return an object with the new information.
return {
plugin,
file,
size,
};
});
}
/**
* Removes the plugin's `path` option from a filepath that starts with it.
* @param {string} filepath The file path to _"normalize"_.
* @return {string}
* @access protected
* @ignore
*/
_resolveFilepath(filepath) {
return filepath.startsWith(this._options.path) ?
filepath.substr(this._options.path.length) :
filepath;
}
/**
* Gets and formats a file size.
* @param {string} filepath The path to the file.
* @return {string}
* @access protected
* @ignore
*/
_getPrettyFilesize(filepath) {
return prettysize(fs.lstatSync(filepath).size)
.replace(/ Bytes$/g, ' B');
}
/**
* Generates the report table to log the entries.
* @param {Array} entries The list of entries.
* @return {string}
* @access protected
* @ignore
*/
_generateStats(entries) {
// Get the widths for each cell.
const cellsWidth = this._getCellsWidth(entries);
// Define the spacing between columns.
const howMuchSpaceBetweenColumns = 2;
// Generate the string for the spacing between columns.
const spacer = this._addSpaces(howMuchSpaceBetweenColumns);
// Define the _"Headers line"_.
const header = [
'',
colors.white(this._addSpaces(cellsWidth.file, this._reportHeaders.file, false)),
colors.white(this._addSpaces(cellsWidth.size, this._reportHeaders.size)),
colors.white(this._addSpaces(cellsWidth.plugin, this._reportHeaders.plugin)),
]
.join(spacer);
/**
* Define the variable that will hold the file that was generated by Rollup itself. The reason
* it's saved on a string it's because if while looping all the entries, a file that starts
* with the same string as this file is found, it will also be highlighted, as it may be
* a variation of the main file (`gz` or `map`).
*/
let rollupFile = ' ';
// Build the lines for each entry.
const entryLines = entries.map((entry) => {
// Define the cell for the file path.
let file = this._addSpaces(cellsWidth.file, entry.file, false);
// Define the cell for the file size.
let size = this._addSpaces(cellsWidth.size, entry.size);
// Define the cell for the plugin's name.
let plugin = this._addSpaces(cellsWidth.plugin, entry.plugin);
// Validate if the entry was generated by Rollup itself.
const isRollupFile = entry.plugin === 'rollup';
// If the entry was generated by Rollup, save the filepath.
if (isRollupFile) {
rollupFile = entry.file;
}
/**
* If the file was generated by Rollup or it's a variation of it (starts with the
* same path)...
*/
if (isRollupFile || entry.file.startsWith(rollupFile)) {
// ...highlight the cells.
file = colors.cyan(file);
size = colors.cyan(size);
plugin = colors.cyan(plugin);
} else {
// ...otherwise, add some regular colors to the cells.
file = colors.green(file);
size = colors.white(size);
plugin = colors.gray(plugin);
}
// Return the line.
return `${spacer}${file}${spacer}${size}${spacer}${plugin}`;
});
// Define all the report lines.
const lines = [
'',
header,
...entryLines,
'',
];
// Return the lines joined on a single string.
return lines.join('\n');
}
/**
* Calculates the width of the report table cells by finding each property longest value.
* @param {Array} entries The entries list.
* @return {ProjextRollupStatsPluginCellsWidth}
* @access protected
* @ignore
*/
_getCellsWidth(entries) {
// Define the initial values.
let longestPlugin = 0;
let longestFile = 0;
let longestSize = 0;
// Loop all the entries and the headers.
[
...entries,
this._reportHeaders,
]
.forEach((entry) => {
// Validate the longest plugin name.
if (entry.plugin.length > longestPlugin) {
longestPlugin = entry.plugin.length;
}
// Validate the longest file path.
if (entry.file.length > longestFile) {
longestFile = entry.file.length;
}
// Validate the longest file size.
if (entry.size.length > longestSize) {
longestSize = entry.size.length;
}
});
// Return the width for each cell type.
return {
plugin: longestPlugin,
file: longestFile,
size: longestSize,
};
}
/**
* Prefix or sufix an string with a number of spaces.
* @param {number} length How many spaces should be added.
* @param {string} [str=''] The string to prefix or sufix.
* @param {boolean} [after=true] Whether the spaces should be after or before the string.
* @return {string}
* @access protected
* @ignore
*/
_addSpaces(length, str = '', after = true) {
const spaces = (new Array(length - str.length)).fill(' ').join('');
return after ? `${str}${spaces}` : `${spaces}${str}`;
}
}
/**
* Shorthand method to create an instance of {@link ProjextRollupStatsPlugin}.
* @param {ProjextRollupStatsPluginOptions} options
* The options to customize the plugin behaviour.
* @param {string} name
* The name of the plugin's instance.
* @return {ProjextRollupStatsPlugin}
*/
const stats = (options, name) => new ProjextRollupStatsPlugin(options, name);
module.exports = {
ProjextRollupStatsPlugin,
stats,
};