@oclif/table
Version:
Display table in terminal
239 lines (238 loc) • 10 kB
JavaScript
import { camelCase, capitalCase, constantCase, kebabCase, pascalCase, sentenceCase, snakeCase } from 'change-case';
import { orderBy } from 'natural-orderby';
import { env } from 'node:process';
import stringWidth from 'string-width';
import stripAnsi from 'strip-ansi';
/**
* Intersperses a list of elements with another element.
*
* @example
* ```js
* intersperse(() => 'foo', [1, 2, 3]) // => [1, 'foo', 2, 'foo', 3]
* ```
*/
export function intersperse(intersperser, elements) {
// Intersperse by reducing from left.
const interspersed = elements.reduce((acc, element, index) => {
// Only add element if it's the first one.
if (acc.length === 0)
return [element];
// Add the intersperser as well otherwise.
return [...acc, intersperser(index), element];
}, []);
return interspersed;
}
export function sortData(data, sort) {
if (!sort)
return data;
const identifiers = Object.keys(sort);
const orders = Object.values(sort);
return orderBy(data, identifiers, orders);
}
export function allKeysInCollection(data) {
const keys = new Set();
for (const row of data) {
for (const key in row) {
if (key in row)
keys.add(key);
}
}
return [...keys];
}
export function determineWidthOfWrappedText(text) {
const lines = text.split('\n');
return lines.reduce((max, line) => Math.max(max, stringWidth(line)), 0);
}
/**
* Gets the width of the terminal column.
* First checks for an override in the OCLIF_TABLE_COLUMN_OVERRIDE environment variable.
* If no override is set or the override is 0, returns the actual terminal width from process.stdout.columns.
*
* It's possible that `process.stdout.columns` is undefined or 0, which is okay. We'll end up using the table's natural width
* in that case. If that renders poorly for the user, they can set the OCLIF_TABLE_COLUMN_OVERRIDE environment variable to a
* non-zero value.
*
* @returns {number} The width of the terminal column
*/
export function getColumnWidth() {
return Number.parseInt(process.env.OCLIF_TABLE_COLUMN_OVERRIDE || '0', 10) || process.stdout.columns;
}
/**
* Determines the configured width based on the provided width value.
* If no width is provided, it returns the width of the current terminal.
* - It's possible that `process.stdout.columns` is undefined, which is okay. We'll end up using the table's natural width.
* If the provided width is a percentage, it calculates the width based on the percentage of the terminal width.
* If the provided width is a number, it returns the provided width.
* If the calculated width is greater than the terminal width, it returns the terminal width.
*
* @param providedWidth - The width value provided.
* @returns The determined configured width.
*/
export function determineConfiguredWidth(providedWidth, columns = getColumnWidth()) {
if (!providedWidth)
return columns;
if (providedWidth === 'none')
return Infinity;
const num = typeof providedWidth === 'string' && providedWidth.endsWith('%')
? Math.floor((Number.parseInt(providedWidth, 10) / 100) * columns)
: typeof providedWidth === 'string'
? Number.parseInt(providedWidth, 10)
: providedWidth;
if (num > columns) {
return columns;
}
return num;
}
export function getColumns(config, headings) {
const { columns, horizontalAlignment, maxWidth, overflow, verticalAlignment, width } = config;
const widths = columns.map((propsOrKey) => {
const props = typeof propsOrKey === 'object' ? propsOrKey : { key: propsOrKey };
const { key } = props;
const padding = props.padding ?? config.padding;
// Get the width of each cell in the column
const data = config.data.map((data) => {
const value = data[key];
if (value === undefined || value === null)
return 0;
// Some terminals don't play nicely with zero-width characters, so we replace them with spaces.
// https://github.com/sindresorhus/terminal-link/issues/18
// https://github.com/Shopify/cli/pull/995
return determineWidthOfWrappedText(stripAnsi(String(value).replaceAll('', ' ')));
});
const header = stringWidth(String(headings[key]));
// If a column width is provided, use that. Otherwise, use the width of the largest cell in the column.
const columnWidth = props.width
? determineConfiguredWidth(props.width, width ?? maxWidth)
: Math.max(...data, header) + padding * 2;
return {
column: key,
horizontalAlignment: props.horizontalAlignment ?? horizontalAlignment,
key: String(key),
overflow: props.overflow ?? overflow,
padding,
verticalAlignment: props.verticalAlignment ?? verticalAlignment,
width: columnWidth,
};
});
const numberOfBorders = widths.length + 1;
const calculateTableWidth = (widths) => widths.map((w) => w.width).reduce((a, b) => a + b, 0) + numberOfBorders;
let tableWidth = calculateTableWidth(widths);
const seen = new Set();
const reduceColumnWidths = (calcMinWidth) => {
// maxWidth === 0 is likely from a test environment where process.stdout.columns is undefined or 0
// In that case, we don't want to reduce the column widths and just use the table's natural width.
if (maxWidth === 0)
return;
// The consumer has indicated they want an unbounded table
if (maxWidth === Infinity)
return;
// If the table is too wide, reduce the width of the largest column as little as possible to fit the table.
// If the table is still too wide, it will reduce the width of the next largest column and so on
while (tableWidth > maxWidth) {
const largestColumn = widths.filter((w) => !seen.has(w.key)).sort((a, b) => b.width - a.width)[0];
if (!largestColumn)
break;
if (seen.has(largestColumn.key))
break;
const minWidth = calcMinWidth(largestColumn);
const difference = tableWidth - maxWidth;
const newWidth = largestColumn.width - difference < minWidth ? minWidth : largestColumn.width - difference;
largestColumn.width = newWidth;
tableWidth = calculateTableWidth(widths);
seen.add(largestColumn.key);
}
};
// At most, reduce the width to the length of the column's header plus padding.
reduceColumnWidths((col) => stringWidth(stripAnsi(String(headings[col.key]))) + col.padding * 2);
seen.clear();
// At most, reduce the width to the padding + 3
reduceColumnWidths((col) => col.padding * 2 + 3);
// If the table width was provided AND it's greater than the calculated table width, expand the columns to fill the width
if (width && width > tableWidth) {
const extraWidth = width - tableWidth;
const extraWidthPerColumn = Math.floor(extraWidth / widths.length);
for (const w of widths) {
w.width += extraWidthPerColumn;
// if it's the last column, add all the remaining width
if (w === widths.at(-1)) {
w.width += extraWidth - extraWidthPerColumn * widths.length;
}
}
}
return widths;
}
export function getHeadings(config) {
const { columns, headerOptions: { formatter }, } = config;
const format = (header) => {
if (typeof header !== 'string')
return header;
if (!formatter)
return header;
if (typeof formatter === 'function')
return formatter(header);
switch (formatter) {
case 'camelCase': {
return camelCase(header);
}
case 'capitalCase': {
return capitalCase(header);
}
case 'constantCase': {
return constantCase(header);
}
case 'kebabCase': {
return kebabCase(header);
}
case 'pascalCase': {
return pascalCase(header);
}
case 'sentenceCase': {
return sentenceCase(header);
}
case 'snakeCase': {
return snakeCase(header);
}
default: {
return header;
}
}
};
return Object.fromEntries(columns.map((c) => {
const key = typeof c === 'object' ? c.key : c;
const name = typeof c === 'object' ? (c.name ?? format(key)) : format(c);
return [key, name];
}));
}
export function maybeStripAnsi(data, noStyle) {
if (!noStyle)
return data;
const newData = [];
for (const row in data) {
if (row in data) {
const newRow = Object.fromEntries(Object.entries(data[row]).map(([key, value]) => [key, typeof value === 'string' ? stripAnsi(value) : value]));
newData.push(newRow);
}
}
return newData;
}
function isTruthy(value) {
return value !== '0' && value !== 'false';
}
/**
* Determines whether the plain text table should be used.
*
* If the OCLIF_TABLE_SKIP_CI_CHECK environment variable is set to a truthy value, the CI check will be skipped.
*
* If the CI environment variable is set, the plain text table will be used.
*
* @returns {boolean} True if the plain text table should be used, false otherwise.
*/
export function shouldUsePlainTable() {
if (env.OCLIF_TABLE_SKIP_CI_CHECK && isTruthy(env.OCLIF_TABLE_SKIP_CI_CHECK))
return false;
// Inspired by https://github.com/sindresorhus/is-in-ci
if (isTruthy(env.CI) &&
('CI' in env || 'CONTINUOUS_INTEGRATION' in env || Object.keys(env).some((key) => key.startsWith('CI_'))))
return true;
return false;
}