UNPKG

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