index.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. 'use strict';
  2. const stringWidth = require('string-width');
  3. const chalk = require('chalk');
  4. const widestLine = require('widest-line');
  5. const cliBoxes = require('cli-boxes');
  6. const camelCase = require('camelcase');
  7. const ansiAlign = require('ansi-align');
  8. const wrapAnsi = require('wrap-ansi');
  9. const NL = '\n';
  10. const PAD = ' ';
  11. const terminalColumns = () => {
  12. const {env, stdout, stderr} = process;
  13. if (stdout && stdout.columns) {
  14. return stdout.columns;
  15. }
  16. if (stderr && stderr.columns) {
  17. return stderr.columns;
  18. }
  19. if (env.COLUMNS) {
  20. return Number.parseInt(env.COLUMNS, 10);
  21. }
  22. return 80;
  23. };
  24. const getObject = detail => {
  25. return typeof detail === 'number' ? {
  26. top: detail,
  27. right: detail * 3,
  28. bottom: detail,
  29. left: detail * 3
  30. } : {
  31. top: 0,
  32. right: 0,
  33. bottom: 0,
  34. left: 0,
  35. ...detail
  36. };
  37. };
  38. const getBorderChars = borderStyle => {
  39. const sides = [
  40. 'topLeft',
  41. 'topRight',
  42. 'bottomRight',
  43. 'bottomLeft',
  44. 'vertical',
  45. 'horizontal'
  46. ];
  47. let chararacters;
  48. if (typeof borderStyle === 'string') {
  49. chararacters = cliBoxes[borderStyle];
  50. if (!chararacters) {
  51. throw new TypeError(`Invalid border style: ${borderStyle}`);
  52. }
  53. } else {
  54. for (const side of sides) {
  55. if (!borderStyle[side] || typeof borderStyle[side] !== 'string') {
  56. throw new TypeError(`Invalid border style: ${side}`);
  57. }
  58. }
  59. chararacters = borderStyle;
  60. }
  61. return chararacters;
  62. };
  63. const makeTitle = (text, horizontal, alignement) => {
  64. let title = '';
  65. const textWidth = stringWidth(text);
  66. switch (alignement) {
  67. case 'left':
  68. title = text + horizontal.slice(textWidth);
  69. break;
  70. case 'right':
  71. title = horizontal.slice(textWidth) + text;
  72. break;
  73. default:
  74. horizontal = horizontal.slice(textWidth);
  75. if (horizontal.length % 2 === 1) { // This is needed in case the length is odd
  76. horizontal = horizontal.slice(Math.floor(horizontal.length / 2));
  77. title = horizontal.slice(1) + text + horizontal; // We reduce the left part of one character to avoid the bar to go beyond its limit
  78. } else {
  79. horizontal = horizontal.slice(horizontal.length / 2);
  80. title = horizontal + text + horizontal;
  81. }
  82. break;
  83. }
  84. return title;
  85. };
  86. const makeContentText = (text, padding, columns, align) => {
  87. text = ansiAlign(text, {align});
  88. let lines = text.split(NL);
  89. const textWidth = widestLine(text);
  90. const max = columns - padding.left - padding.right;
  91. if (textWidth > max) {
  92. const newLines = [];
  93. for (const line of lines) {
  94. const createdLines = wrapAnsi(line, max, {hard: true});
  95. const alignedLines = ansiAlign(createdLines, {align});
  96. const alignedLinesArray = alignedLines.split('\n');
  97. const longestLength = Math.max(...alignedLinesArray.map(s => stringWidth(s)));
  98. for (const alignedLine of alignedLinesArray) {
  99. let paddedLine;
  100. switch (align) {
  101. case 'center':
  102. paddedLine = PAD.repeat((max - longestLength) / 2) + alignedLine;
  103. break;
  104. case 'right':
  105. paddedLine = PAD.repeat(max - longestLength) + alignedLine;
  106. break;
  107. default:
  108. paddedLine = alignedLine;
  109. break;
  110. }
  111. newLines.push(paddedLine);
  112. }
  113. }
  114. lines = newLines;
  115. }
  116. if (align === 'center' && textWidth < max) {
  117. lines = lines.map(line => PAD.repeat((max - textWidth) / 2) + line);
  118. } else if (align === 'right' && textWidth < max) {
  119. lines = lines.map(line => PAD.repeat(max - textWidth) + line);
  120. }
  121. const paddingLeft = PAD.repeat(padding.left);
  122. const paddingRight = PAD.repeat(padding.right);
  123. lines = lines.map(line => paddingLeft + line + paddingRight);
  124. lines = lines.map(line => {
  125. if (columns - stringWidth(line) > 0) {
  126. switch (align) {
  127. case 'center':
  128. return line + PAD.repeat(columns - stringWidth(line));
  129. case 'right':
  130. return line + PAD.repeat(columns - stringWidth(line));
  131. default:
  132. return line + PAD.repeat(columns - stringWidth(line));
  133. }
  134. }
  135. return line;
  136. });
  137. if (padding.top > 0) {
  138. lines = new Array(padding.top).fill(PAD.repeat(columns)).concat(lines);
  139. }
  140. if (padding.bottom > 0) {
  141. lines = lines.concat(new Array(padding.bottom).fill(PAD.repeat(columns)));
  142. }
  143. return lines.join(NL);
  144. };
  145. const isHex = color => color.match(/^#(?:[0-f]{3}){1,2}$/i);
  146. const isColorValid = color => typeof color === 'string' && ((chalk[color]) || isHex(color));
  147. const getColorFn = color => isHex(color) ? chalk.hex(color) : chalk[color];
  148. const getBGColorFn = color => isHex(color) ? chalk.bgHex(color) : chalk[camelCase(['bg', color])];
  149. module.exports = (text, options) => {
  150. options = {
  151. padding: 0,
  152. borderStyle: 'single',
  153. dimBorder: false,
  154. textAlignment: 'left',
  155. float: 'left',
  156. titleAlignment: 'left',
  157. ...options
  158. };
  159. // This option is deprecated
  160. if (options.align) {
  161. options.textAlignment = options.align;
  162. }
  163. const BORDERS_WIDTH = 2;
  164. if (options.borderColor && !isColorValid(options.borderColor)) {
  165. throw new Error(`${options.borderColor} is not a valid borderColor`);
  166. }
  167. if (options.backgroundColor && !isColorValid(options.backgroundColor)) {
  168. throw new Error(`${options.backgroundColor} is not a valid backgroundColor`);
  169. }
  170. const chars = getBorderChars(options.borderStyle);
  171. const padding = getObject(options.padding);
  172. const margin = getObject(options.margin);
  173. const colorizeBorder = border => {
  174. const newBorder = options.borderColor ? getColorFn(options.borderColor)(border) : border;
  175. return options.dimBorder ? chalk.dim(newBorder) : newBorder;
  176. };
  177. const colorizeContent = content => options.backgroundColor ? getBGColorFn(options.backgroundColor)(content) : content;
  178. const columns = terminalColumns();
  179. let contentWidth = widestLine(wrapAnsi(text, columns - BORDERS_WIDTH, {hard: true, trim: false})) + padding.left + padding.right;
  180. // This prevents the title bar to exceed the console's width
  181. let title = options.title && options.title.slice(0, columns - 4 - margin.left - margin.right);
  182. if (title) {
  183. title = ` ${title} `;
  184. // Make the box larger to fit a larger title
  185. if (stringWidth(title) > contentWidth) {
  186. contentWidth = stringWidth(title);
  187. }
  188. }
  189. if ((margin.left && margin.right) && contentWidth + BORDERS_WIDTH + margin.left + margin.right > columns) {
  190. // Let's assume we have margins: left = 3, right = 5, in total = 8
  191. const spaceForMargins = columns - contentWidth - BORDERS_WIDTH;
  192. // Let's assume we have space = 4
  193. const multiplier = spaceForMargins / (margin.left + margin.right);
  194. // Here: multiplier = 4/8 = 0.5
  195. margin.left = Math.max(0, Math.floor(margin.left * multiplier));
  196. margin.right = Math.max(0, Math.floor(margin.right * multiplier));
  197. // Left: 3 * 0.5 = 1.5 -> 1
  198. // Right: 6 * 0.5 = 3
  199. }
  200. // Prevent content from exceeding the console's width
  201. contentWidth = Math.min(contentWidth, columns - BORDERS_WIDTH - margin.left - margin.right);
  202. text = makeContentText(text, padding, contentWidth, options.textAlignment);
  203. let marginLeft = PAD.repeat(margin.left);
  204. if (options.float === 'center') {
  205. const marginWidth = Math.max((columns - contentWidth - BORDERS_WIDTH) / 2, 0);
  206. marginLeft = PAD.repeat(marginWidth);
  207. } else if (options.float === 'right') {
  208. const marginWidth = Math.max(columns - contentWidth - margin.right - BORDERS_WIDTH, 0);
  209. marginLeft = PAD.repeat(marginWidth);
  210. }
  211. const horizontal = chars.horizontal.repeat(contentWidth);
  212. const top = colorizeBorder(NL.repeat(margin.top) + marginLeft + chars.topLeft + (title ? makeTitle(title, horizontal, options.titleAlignment) : horizontal) + chars.topRight);
  213. const bottom = colorizeBorder(marginLeft + chars.bottomLeft + horizontal + chars.bottomRight + NL.repeat(margin.bottom));
  214. const side = colorizeBorder(chars.vertical);
  215. const LINE_SEPARATOR = (contentWidth + BORDERS_WIDTH + margin.left >= columns) ? '' : NL;
  216. const lines = text.split(NL);
  217. const middle = lines.map(line => {
  218. return marginLeft + side + colorizeContent(line) + side;
  219. }).join(LINE_SEPARATOR);
  220. return top + LINE_SEPARATOR + middle + LINE_SEPARATOR + bottom;
  221. };
  222. module.exports._borderStyles = cliBoxes;