@fontoxml/fontoxml-development-tools
Version:
Development tools for Fonto.
545 lines (485 loc) • 13 kB
JavaScript
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
)}`
);
}
}