UNPKG

@fontoxml/fontoxml-development-tools

Version:

Development tools for Fonto.

545 lines (485 loc) 13 kB
import Table from 'cli-table'; import os from 'os'; import stripAnsi from 'strip-ansi'; import util from 'util'; import InputError from '../request/InputError.js'; import extras from './extras.js'; import primitives from './primitives.js'; const DEFAULT_CONFIG = { defaultWidth: extras.defaultWidth, indentation: ' ', defaultIndentation: 1, tableCharacters: extras.expandedTable, spinnerFactory: extras.spriteSpinner, spinnerInterval: 200, stdout: process.stdout, }; const DESTROYERS = Symbol(); const LOG = Symbol(); const RAW = Symbol(); const WIDTH = Symbol(); class ErrorWithSolution extends InputError { /** * A Error object which can also have a solution and/or inner Error. * * @param {string} message * @param {string} [solution] * @param {Error} [innerError] * @param {string} [code] */ constructor(message, solution, innerError, code) { super(message, solution, code); this.innerError = innerError; // A workaround to make `instanceof ErrorWithSolution` work. this.constructor = ErrorWithSolution; this.__proto__ = ErrorWithSolution.prototype; } } class ErrorWithInnerError extends ErrorWithSolution { /** * A Error object which can also have an inner Error. * * @param {string} message * @param {Error} [innerError] * @param {string} [code] */ constructor(message, innerError, code) { super(message, undefined, innerError, code); // A workaround to make `instanceof ErrorWithInnerError` work. this.constructor = ErrorWithInnerError; this.__proto__ = ErrorWithInnerError.prototype; } } export default class FdtResponse { /** * * @param {Object} [colors] * @param {Object} [config] */ constructor(colors, config) { // Write the color config to instance, and convert values to arrays if they weren't already. this.colors = { ...extras.defaultTheme, ...colors }; Object.keys(this.colors).forEach((colorName) => { if (!this.colors[colorName]) { this.colors[colorName] = ['dim']; } else if (!Array.isArray(this.colors[colorName])) { this.colors[colorName] = [this.colors[colorName]]; } }); // Write all other config to instance. this.config = { ...DEFAULT_CONFIG, ...config }; // If set to TRUE, the next log will overwrite the previous output. /** @type {boolean} */ this.needsClearing = false; // The number of indents the next log will have. /** @type {number} */ this.indentationLevel = this.config.defaultIndentation; // The maximum width of any line logged by FdtResponse. /** @type {number} */ this[WIDTH] = primitives.getTerminalWidth() || this.config.defaultWidth; // A list of interval destroyers. /** @type {(() => void)[]} */ this[DESTROYERS] = []; /** @type {ErrorWithInnerError} */ this.ErrorWithInnerError = ErrorWithInnerError; /** @type {ErrorWithSolution} */ this.ErrorWithSolution = ErrorWithSolution; /** @type {InputError} */ this.InputError = InputError; } clearIfNeeded() { if ( this.needsClearing && typeof this.config.stdout.clearLine === 'function' ) { this.config.stdout.clearLine(); this.config.stdout.cursorTo(0); this.needsClearing = false; } } [RAW](data) { this.config.stdout.write(data); } [LOG](string, formattingOptions, skipLineBreak) { this.clearIfNeeded(); this[RAW]( primitives.indentString( primitives.formatString(string, formattingOptions), primitives.getLeftIndentationString( this.config.indentation, this.indentationLevel ), this.config.indentation, this[WIDTH] ) + (skipLineBreak ? '' : os.EOL) ); } getWidth() { return this[WIDTH]; } setWrapping(width) { if (width === true) { this[WIDTH] = primitives.getTerminalWidth(); } else if (width) { this[WIDTH] = parseInt(width, 10); } else { this[WIDTH] = Infinity; } } indent() { ++this.indentationLevel; } outdent() { this.indentationLevel = Math.max( this.config.defaultIndentation, this.indentationLevel - 1 ); } /** * A description of proceedings relevant to the task/whatever. * * @param {*} data * @param {boolean} [skipLineBreak] */ log(data, skipLineBreak) { this[LOG](data, this.colors.log, skipLineBreak); } /** * Indicates that something the user wanted happened. * * @param {*} data * @param {boolean} [skipLineBreak] */ success(data, skipLineBreak) { this[LOG](data, this.colors.success, skipLineBreak); } /** * Indicates the app is working on a concern/task/whatever. * * @param {*} data * @param {boolean} [skipLineBreak] */ caption(data, skipLineBreak) { this.clearIfNeeded(); this.break(); this[LOG](data, this.colors.caption, skipLineBreak); } /** * Something that is probably of interest (but not necessarily bad), if not important, for the user; exceptions, search results, urgent stuff. * * @param {*} data * @param {boolean} [skipLineBreak] */ notice(data, skipLineBreak) { this[LOG](data, this.colors.notice, skipLineBreak); } /** * Something messed up. * * @param {*} data * @param {boolean} [skipLineBreak] */ error(data, skipLineBreak) { this[LOG](data, this.colors.error, skipLineBreak); } /** * Information that the user might not even care about at that time. * * @param {*} data * @param {boolean} [skipLineBreak] */ debug(data, skipLineBreak) { this[LOG]( data && typeof data === 'object' ? util.inspect(data, { depth: 3, colors: false }) : data, this.colors.debug, skipLineBreak ); } definition(key, value, formattingName, skipLineBreak) { this[LOG](key, this.colors.definitionKey); this[RAW]( primitives.indentString( primitives.formatString( value, formattingName ? this.colors[formattingName] : this.colors.definitionValue ), primitives.getLeftIndentationString( this.config.indentation, this.indentationLevel + 1 ), this.config.indentation, this[WIDTH] ) + (skipLineBreak ? '' : os.EOL) ); } property(key, value, keySize, formattingName, skipLineBreak) { keySize = keySize || 0; const keyString = primitives.indentString( primitives.formatString( primitives.padString(key, keySize), this.colors.propertyKey ), primitives.getLeftIndentationString( this.config.indentation, this.indentationLevel ), this.config.indentation, this[WIDTH] ); const seperatorString = ''; // used to pad the value of a property this.clearIfNeeded(); this[RAW]( primitives .indentString( primitives.formatString( value, formattingName ? this.colors[formattingName] : this.colors.propertyValue ), seperatorString, seperatorString, this[WIDTH] - (1 + this.indentationLevel) * this.config.indentation.length - seperatorString.length - keySize ) .split('\n') .map( (line, i) => (i === 0 ? keyString : primitives.fillString( keySize + (this.indentationLevel + 1) * this.config.indentation.length + 1 )) + line ) .join('\n') + (skipLineBreak ? '' : os.EOL) ); } properties(obj, formattingName) { let maxLength = 0; if (Array.isArray(obj)) { obj.forEach((k) => { maxLength = Math.max((k[0] || '').length, maxLength); }); obj.forEach((k) => { this.property(k[0], k[1], maxLength, k[2] || formattingName); }); } else { Object.keys(obj).forEach((k) => { maxLength = Math.max(k.length, maxLength); }); Object.keys(obj).forEach((k) => { this.property(k, obj[k], maxLength, formattingName); }); } } /** * Information that should be outputted without formatting. * * @param {*} data */ raw(data) { this[RAW](data); } /** * Whenever you need a little peace & quiet in your life, use break(). */ break() { this[RAW](os.EOL); } /** * Stop and remove any remaining spinners. */ destroyAllSpinners() { this[DESTROYERS].forEach((fn) => fn()); } /** * Nice little ASCII animation that runs while the destroyer is not called. * * @param {string} message * @param {boolean} [skipLineBreak] * * @return {function(Type, Type): Type} The destroyer. */ spinner(message, skipLineBreak) { const startTime = new Date().getTime(); const hasClearLine = typeof this.config.stdout.clearLine === 'function'; if (!hasClearLine) { // When there is no support for clearing the line and moving the cursor, do nothing and // just output the message and duration when done. const destroySpinnerWithoutClearLine = () => { if (!this[DESTROYERS].includes(destroySpinnerWithoutClearLine)) { return; } const ms = new Date().getTime() - startTime; this[LOG]( `${message} (${ms}ms)`, this.colors.spinnerDone, skipLineBreak ); this[DESTROYERS].splice( this[DESTROYERS].indexOf(destroySpinnerWithoutClearLine), 1 ); }; this[DESTROYERS].push(destroySpinnerWithoutClearLine); return destroySpinnerWithoutClearLine; } const formattedMessageWithAnsi = primitives .indentString( primitives.formatString(message, this.colors.spinnerSpinning), primitives.getLeftIndentationString( this.config.indentation, this.indentationLevel ), this.config.indentation, this.getWidth() ) .replace(new RegExp(`${this.config.indentation}$`), ''); const formattedMessageWithoutAnsi = stripAnsi(formattedMessageWithAnsi); const drawSpinner = this.config.spinnerFactory( this, message, formattedMessageWithoutAnsi, formattedMessageWithAnsi ); const interval = setInterval(() => { if (!this.needsClearing) { // Redraw the message when a log message is outputted in between the spinner output this[RAW](formattedMessageWithAnsi); } drawSpinner(null, !this.needsClearing); this.needsClearing = true; }, this.config.spinnerInterval); const destroySpinner = () => { if (!this[DESTROYERS].includes(destroySpinner)) { return; } const ms = new Date().getTime() - startTime; if (!this.needsClearing) { // Redraw the message when a log message is outputted in between the spinner output this[RAW](formattedMessageWithAnsi); } drawSpinner(`(${ms}ms)`, !this.needsClearing); this[RAW](os.EOL); clearInterval(interval); this[DESTROYERS].splice( this[DESTROYERS].indexOf(destroySpinner), 1 ); }; this[DESTROYERS].push(destroySpinner); this[RAW](formattedMessageWithAnsi); this.needsClearing = true; drawSpinner(); return destroySpinner; } table(columnNames, content, expanded) { const columnSizes = []; const totalWidth = Math.min( this[WIDTH] - (this.indentationLevel + 1) * this.config.indentation.length, 800 ); const columnSeperator = ' '; content = content.map((row) => row.map((cell, colIndex) => { cell = `${cell}`; if (!columnSizes[colIndex]) { columnSizes[colIndex] = columnNames[colIndex].length; } let cellLength = cell.length; if (cell.includes('\n')) { cellLength = cell .split('\n') .reduce((max, line) => Math.max(max, line.length), 0); } if (cellLength > columnSizes[colIndex]) { columnSizes[colIndex] = cellLength; } return cell.trim(); }) ); const totalContentAvailableWidth = totalWidth - columnNames.length * columnSeperator.length; const totalContentNativeWidth = columnSizes.reduce( (total, size) => total + size, 0 ); const contentRelativeSizes = totalContentNativeWidth <= totalContentAvailableWidth ? columnSizes : columnSizes.map((size) => Math.ceil( totalContentAvailableWidth * (size / totalContentNativeWidth) ) ); const table = new Table({ head: columnNames || [], colWidths: contentRelativeSizes, chars: !expanded ? extras.compactTable : this.config.tableCharacters, style: { 'padding-left': 0, 'padding-right': 0, compact: !expanded, head: this.colors.tableHeader || [], border: this.colors.debug || [], }, }); content.forEach((cont) => table.push( cont.map((c, i) => { return c.length > contentRelativeSizes[i] ? primitives.wrap(c, contentRelativeSizes[i]) : c; }) ) ); this.clearIfNeeded(); table .toString() .split('\n') .map( (line) => primitives.getLeftIndentationString( this.config.indentation, this.indentationLevel ) + line ) .forEach((line) => { this[RAW](line + os.EOL); }); } list(listItems, bulletCharacter) { listItems.forEach((listItem, i) => { this.listItem( listItem, bulletCharacter ? bulletCharacter : `${i + 1}.` ); }); } listItem(value, bulletCharacter, skipLineBreak) { this[LOG]( `${primitives.formatString( bulletCharacter, this.colors.listItemBullet )} ${primitives.formatString( value, this.colors.listItemValue, skipLineBreak )}` ); } }