3 const wcwidth = require('./width')
11 } = require('./utils')
13 const DEFAULT_HEADING_TRANSFORM = key => key.toUpperCase()
15 const DEFAULT_DATA_TRANSFORM = (cell, column, index) => cell
17 const DEFAULTS = Object.freeze({
23 preserveNewLines: false,
26 headingTransform: DEFAULT_HEADING_TRANSFORM,
27 dataTransform: DEFAULT_DATA_TRANSFORM
30 module.exports = function(items, options = {}) {
32 let columnConfigs = options.config || {}
33 delete options.config // remove config so doesn't appear on every column.
35 let maxLineWidth = options.maxLineWidth || Infinity
36 if (maxLineWidth === 'auto') maxLineWidth = process.stdout.columns || Infinity
37 delete options.maxLineWidth // this is a line control option, don't pass it to column
39 // Option defaults inheritance:
40 // options.config[columnName] => options => DEFAULTS
41 options = mixin({}, DEFAULTS, options)
43 options.config = options.config || Object.create(null)
45 options.spacing = options.spacing || '\n' // probably useless
46 options.preserveNewLines = !!options.preserveNewLines
47 options.showHeaders = !!options.showHeaders;
48 options.columns = options.columns || options.include // alias include/columns, prefer columns if supplied
49 let columnNames = options.columns || [] // optional user-supplied columns to include
51 items = toArray(items, columnNames)
53 // if not suppled column names, automatically determine columns from data keys
54 if (!columnNames.length) {
55 items.forEach(function(item) {
56 for (let columnName in item) {
57 if (columnNames.indexOf(columnName) === -1) columnNames.push(columnName)
62 // initialize column defaults (each column inherits from options.config)
63 let columns = columnNames.reduce((columns, columnName) => {
64 let column = Object.create(options)
65 columns[columnName] = mixin(column, columnConfigs[columnName])
67 }, Object.create(null))
69 // sanitize column settings
70 columnNames.forEach(columnName => {
71 let column = columns[columnName]
72 column.name = columnName
73 column.maxWidth = Math.ceil(column.maxWidth)
74 column.minWidth = Math.ceil(column.minWidth)
75 column.truncate = !!column.truncate
76 column.align = column.align || 'left'
80 items = items.map(item => {
81 let result = Object.create(null)
82 columnNames.forEach(columnName => {
83 // null/undefined -> ''
84 result[columnName] = item[columnName] != null ? item[columnName] : ''
85 // toString everything
86 result[columnName] = '' + result[columnName]
87 if (columns[columnName].preserveNewLines) {
88 // merge non-newline whitespace chars
89 result[columnName] = result[columnName].replace(/[^\S\n]/gmi, ' ')
91 // merge all whitespace chars
92 result[columnName] = result[columnName].replace(/\s/gmi, ' ')
98 // transform data cells
99 columnNames.forEach(columnName => {
100 let column = columns[columnName]
101 items = items.map((item, index) => {
102 let col = Object.create(column)
103 item[columnName] = column.dataTransform(item[columnName], col, index)
105 let changedKeys = Object.keys(col)
106 // disable default heading transform if we wrote to column.name
107 if (changedKeys.indexOf('name') !== -1) {
108 if (column.headingTransform !== DEFAULT_HEADING_TRANSFORM) return
109 column.headingTransform = heading => heading
111 changedKeys.forEach(key => column[key] = col[key])
118 if(options.showHeaders) {
119 columnNames.forEach(columnName => {
120 let column = columns[columnName]
122 if(!column.showHeaders){
123 headers[columnName] = '';
127 headers[columnName] = column.headingTransform(column.name)
129 items.unshift(headers)
131 // get actual max-width between min & max
132 // based on length of data in columns
133 columnNames.forEach(columnName => {
134 let column = columns[columnName]
136 .map(item => item[columnName])
137 .reduce((min, cur) => {
138 // if already at maxWidth don't bother testing
139 if (min >= column.maxWidth) return min
140 return Math.max(min, Math.min(column.maxWidth, Math.max(column.minWidth, wcwidth(cur))))
144 // split long words so they can break onto multiple lines
145 columnNames.forEach(columnName => {
146 let column = columns[columnName]
147 items = items.map(item => {
148 item[columnName] = splitLongWords(item[columnName], column.width, column.truncateMarker)
153 // wrap long lines. each item is now an array of lines.
154 columnNames.forEach(columnName => {
155 let column = columns[columnName]
156 items = items.map((item, index) => {
157 let cell = item[columnName]
158 item[columnName] = splitIntoLines(cell, column.width)
160 // if truncating required, only include first line + add truncation char
161 if (column.truncate && item[columnName].length > 1) {
162 item[columnName] = splitIntoLines(cell, column.width - wcwidth(column.truncateMarker))
163 let firstLine = item[columnName][0]
164 if (!endsWith(firstLine, column.truncateMarker)) item[columnName][0] += column.truncateMarker
165 item[columnName] = item[columnName].slice(0, 1)
171 // recalculate column widths from truncated output/lines
172 columnNames.forEach(columnName => {
173 let column = columns[columnName]
174 column.width = items.map(item => {
175 return item[columnName].reduce((min, cur) => {
176 if (min >= column.maxWidth) return min
177 return Math.max(min, Math.min(column.maxWidth, Math.max(column.minWidth, wcwidth(cur))))
179 }).reduce((min, cur) => {
180 if (min >= column.maxWidth) return min
181 return Math.max(min, Math.min(column.maxWidth, Math.max(column.minWidth, cur)))
186 let rows = createRows(items, columns, columnNames, options.paddingChr) // merge lines into rows
188 return rows.reduce((output, row) => {
189 return output.concat(row.reduce((rowOut, line) => {
190 return rowOut.concat(line.join(options.columnSplitter))
193 .map(line => truncateString(line, maxLineWidth))
194 .join(options.spacing)
198 * Convert wrapped lines into rows with padded values.
200 * @param Array items data to process
201 * @param Array columns column width settings for wrapping
202 * @param Array columnNames column ordering
203 * @return Array items wrapped in arrays, corresponding to lines
206 function createRows(items, columns, columnNames, paddingChr) {
207 return items.map(item => {
210 columnNames.forEach(columnName => {
211 numLines = Math.max(numLines, item[columnName].length)
213 // combine matching lines of each rows
214 for (let i = 0; i < numLines; i++) {
215 row[i] = row[i] || []
216 columnNames.forEach(columnName => {
217 let column = columns[columnName]
218 let val = item[columnName][i] || '' // || '' ensures empty columns get padded
219 if (column.align === 'right') row[i].push(padLeft(val, column.width, paddingChr))
220 else if (column.align === 'center' || column.align === 'centre') row[i].push(padCenter(val, column.width, paddingChr))
221 else row[i].push(padRight(val, column.width, paddingChr))
231 * @return Object Object with properties mixed in.
234 function mixin(...args) {
235 if (Object.assign) return Object.assign(...args)
236 return ObjectAssign(...args)
239 function ObjectAssign(target, firstSource) {
241 if (target === undefined || target === null)
242 throw new TypeError("Cannot convert first argument to object");
244 var to = Object(target);
246 var hasPendingException = false;
247 var pendingException;
249 for (var i = 1; i < arguments.length; i++) {
250 var nextSource = arguments[i];
251 if (nextSource === undefined || nextSource === null)
254 var keysArray = Object.keys(Object(nextSource));
255 for (var nextIndex = 0, len = keysArray.length; nextIndex < len; nextIndex++) {
256 var nextKey = keysArray[nextIndex];
258 var desc = Object.getOwnPropertyDescriptor(nextSource, nextKey);
259 if (desc !== undefined && desc.enumerable)
260 to[nextKey] = nextSource[nextKey];
262 if (!hasPendingException) {
263 hasPendingException = true;
264 pendingException = e;
269 if (hasPendingException)
270 throw pendingException;
276 * Adapted from String.prototype.endsWith polyfill.
279 function endsWith(target, searchString, position) {
280 position = position || target.length;
281 position = position - searchString.length;
282 let lastIndex = target.lastIndexOf(searchString);
283 return lastIndex !== -1 && lastIndex === position;
287 function toArray(items, columnNames) {
288 if (Array.isArray(items)) return items
290 for (let key in items) {
292 item[columnNames[0] || 'key'] = key
293 item[columnNames[1] || 'value'] = items[key]