index.js 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. "use strict"
  2. const wcwidth = require('./width')
  3. const {
  4. padRight,
  5. padCenter,
  6. padLeft,
  7. splitIntoLines,
  8. splitLongWords,
  9. truncateString
  10. } = require('./utils')
  11. const DEFAULT_HEADING_TRANSFORM = key => key.toUpperCase()
  12. const DEFAULT_DATA_TRANSFORM = (cell, column, index) => cell
  13. const DEFAULTS = Object.freeze({
  14. maxWidth: Infinity,
  15. minWidth: 0,
  16. columnSplitter: ' ',
  17. truncate: false,
  18. truncateMarker: '…',
  19. preserveNewLines: false,
  20. paddingChr: ' ',
  21. showHeaders: true,
  22. headingTransform: DEFAULT_HEADING_TRANSFORM,
  23. dataTransform: DEFAULT_DATA_TRANSFORM
  24. })
  25. module.exports = function(items, options = {}) {
  26. let columnConfigs = options.config || {}
  27. delete options.config // remove config so doesn't appear on every column.
  28. let maxLineWidth = options.maxLineWidth || Infinity
  29. if (maxLineWidth === 'auto') maxLineWidth = process.stdout.columns || Infinity
  30. delete options.maxLineWidth // this is a line control option, don't pass it to column
  31. // Option defaults inheritance:
  32. // options.config[columnName] => options => DEFAULTS
  33. options = mixin({}, DEFAULTS, options)
  34. options.config = options.config || Object.create(null)
  35. options.spacing = options.spacing || '\n' // probably useless
  36. options.preserveNewLines = !!options.preserveNewLines
  37. options.showHeaders = !!options.showHeaders;
  38. options.columns = options.columns || options.include // alias include/columns, prefer columns if supplied
  39. let columnNames = options.columns || [] // optional user-supplied columns to include
  40. items = toArray(items, columnNames)
  41. // if not suppled column names, automatically determine columns from data keys
  42. if (!columnNames.length) {
  43. items.forEach(function(item) {
  44. for (let columnName in item) {
  45. if (columnNames.indexOf(columnName) === -1) columnNames.push(columnName)
  46. }
  47. })
  48. }
  49. // initialize column defaults (each column inherits from options.config)
  50. let columns = columnNames.reduce((columns, columnName) => {
  51. let column = Object.create(options)
  52. columns[columnName] = mixin(column, columnConfigs[columnName])
  53. return columns
  54. }, Object.create(null))
  55. // sanitize column settings
  56. columnNames.forEach(columnName => {
  57. let column = columns[columnName]
  58. column.name = columnName
  59. column.maxWidth = Math.ceil(column.maxWidth)
  60. column.minWidth = Math.ceil(column.minWidth)
  61. column.truncate = !!column.truncate
  62. column.align = column.align || 'left'
  63. })
  64. // sanitize data
  65. items = items.map(item => {
  66. let result = Object.create(null)
  67. columnNames.forEach(columnName => {
  68. // null/undefined -> ''
  69. result[columnName] = item[columnName] != null ? item[columnName] : ''
  70. // toString everything
  71. result[columnName] = '' + result[columnName]
  72. if (columns[columnName].preserveNewLines) {
  73. // merge non-newline whitespace chars
  74. result[columnName] = result[columnName].replace(/[^\S\n]/gmi, ' ')
  75. } else {
  76. // merge all whitespace chars
  77. result[columnName] = result[columnName].replace(/\s/gmi, ' ')
  78. }
  79. })
  80. return result
  81. })
  82. // transform data cells
  83. columnNames.forEach(columnName => {
  84. let column = columns[columnName]
  85. items = items.map((item, index) => {
  86. let col = Object.create(column)
  87. item[columnName] = column.dataTransform(item[columnName], col, index)
  88. let changedKeys = Object.keys(col)
  89. // disable default heading transform if we wrote to column.name
  90. if (changedKeys.indexOf('name') !== -1) {
  91. if (column.headingTransform !== DEFAULT_HEADING_TRANSFORM) return
  92. column.headingTransform = heading => heading
  93. }
  94. changedKeys.forEach(key => column[key] = col[key])
  95. return item
  96. })
  97. })
  98. // add headers
  99. let headers = {}
  100. if(options.showHeaders) {
  101. columnNames.forEach(columnName => {
  102. let column = columns[columnName]
  103. if(!column.showHeaders){
  104. headers[columnName] = '';
  105. return;
  106. }
  107. headers[columnName] = column.headingTransform(column.name)
  108. })
  109. items.unshift(headers)
  110. }
  111. // get actual max-width between min & max
  112. // based on length of data in columns
  113. columnNames.forEach(columnName => {
  114. let column = columns[columnName]
  115. column.width = items
  116. .map(item => item[columnName])
  117. .reduce((min, cur) => {
  118. // if already at maxWidth don't bother testing
  119. if (min >= column.maxWidth) return min
  120. return Math.max(min, Math.min(column.maxWidth, Math.max(column.minWidth, wcwidth(cur))))
  121. }, 0)
  122. })
  123. // split long words so they can break onto multiple lines
  124. columnNames.forEach(columnName => {
  125. let column = columns[columnName]
  126. items = items.map(item => {
  127. item[columnName] = splitLongWords(item[columnName], column.width, column.truncateMarker)
  128. return item
  129. })
  130. })
  131. // wrap long lines. each item is now an array of lines.
  132. columnNames.forEach(columnName => {
  133. let column = columns[columnName]
  134. items = items.map((item, index) => {
  135. let cell = item[columnName]
  136. item[columnName] = splitIntoLines(cell, column.width)
  137. // if truncating required, only include first line + add truncation char
  138. if (column.truncate && item[columnName].length > 1) {
  139. item[columnName] = splitIntoLines(cell, column.width - wcwidth(column.truncateMarker))
  140. let firstLine = item[columnName][0]
  141. if (!endsWith(firstLine, column.truncateMarker)) item[columnName][0] += column.truncateMarker
  142. item[columnName] = item[columnName].slice(0, 1)
  143. }
  144. return item
  145. })
  146. })
  147. // recalculate column widths from truncated output/lines
  148. columnNames.forEach(columnName => {
  149. let column = columns[columnName]
  150. column.width = items.map(item => {
  151. return item[columnName].reduce((min, cur) => {
  152. if (min >= column.maxWidth) return min
  153. return Math.max(min, Math.min(column.maxWidth, Math.max(column.minWidth, wcwidth(cur))))
  154. }, 0)
  155. }).reduce((min, cur) => {
  156. if (min >= column.maxWidth) return min
  157. return Math.max(min, Math.min(column.maxWidth, Math.max(column.minWidth, cur)))
  158. }, 0)
  159. })
  160. let rows = createRows(items, columns, columnNames, options.paddingChr) // merge lines into rows
  161. // conceive output
  162. return rows.reduce((output, row) => {
  163. return output.concat(row.reduce((rowOut, line) => {
  164. return rowOut.concat(line.join(options.columnSplitter))
  165. }, []))
  166. }, [])
  167. .map(line => truncateString(line, maxLineWidth))
  168. .join(options.spacing)
  169. }
  170. /**
  171. * Convert wrapped lines into rows with padded values.
  172. *
  173. * @param Array items data to process
  174. * @param Array columns column width settings for wrapping
  175. * @param Array columnNames column ordering
  176. * @return Array items wrapped in arrays, corresponding to lines
  177. */
  178. function createRows(items, columns, columnNames, paddingChr) {
  179. return items.map(item => {
  180. let row = []
  181. let numLines = 0
  182. columnNames.forEach(columnName => {
  183. numLines = Math.max(numLines, item[columnName].length)
  184. })
  185. // combine matching lines of each rows
  186. for (let i = 0; i < numLines; i++) {
  187. row[i] = row[i] || []
  188. columnNames.forEach(columnName => {
  189. let column = columns[columnName]
  190. let val = item[columnName][i] || '' // || '' ensures empty columns get padded
  191. if (column.align === 'right') row[i].push(padLeft(val, column.width, paddingChr))
  192. else if (column.align === 'center' || column.align === 'centre') row[i].push(padCenter(val, column.width, paddingChr))
  193. else row[i].push(padRight(val, column.width, paddingChr))
  194. })
  195. }
  196. return row
  197. })
  198. }
  199. /**
  200. * Object.assign
  201. *
  202. * @return Object Object with properties mixed in.
  203. */
  204. function mixin(...args) {
  205. if (Object.assign) return Object.assign(...args)
  206. return ObjectAssign(...args)
  207. }
  208. function ObjectAssign(target, firstSource) {
  209. "use strict";
  210. if (target === undefined || target === null)
  211. throw new TypeError("Cannot convert first argument to object");
  212. var to = Object(target);
  213. var hasPendingException = false;
  214. var pendingException;
  215. for (var i = 1; i < arguments.length; i++) {
  216. var nextSource = arguments[i];
  217. if (nextSource === undefined || nextSource === null)
  218. continue;
  219. var keysArray = Object.keys(Object(nextSource));
  220. for (var nextIndex = 0, len = keysArray.length; nextIndex < len; nextIndex++) {
  221. var nextKey = keysArray[nextIndex];
  222. try {
  223. var desc = Object.getOwnPropertyDescriptor(nextSource, nextKey);
  224. if (desc !== undefined && desc.enumerable)
  225. to[nextKey] = nextSource[nextKey];
  226. } catch (e) {
  227. if (!hasPendingException) {
  228. hasPendingException = true;
  229. pendingException = e;
  230. }
  231. }
  232. }
  233. if (hasPendingException)
  234. throw pendingException;
  235. }
  236. return to;
  237. }
  238. /**
  239. * Adapted from String.prototype.endsWith polyfill.
  240. */
  241. function endsWith(target, searchString, position) {
  242. position = position || target.length;
  243. position = position - searchString.length;
  244. let lastIndex = target.lastIndexOf(searchString);
  245. return lastIndex !== -1 && lastIndex === position;
  246. }
  247. function toArray(items, columnNames) {
  248. if (Array.isArray(items)) return items
  249. let rows = []
  250. for (let key in items) {
  251. let item = {}
  252. item[columnNames[0] || 'key'] = key
  253. item[columnNames[1] || 'value'] = items[key]
  254. rows.push(item)
  255. }
  256. return rows
  257. }