UNPKG

11.3 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 = ' ';
12const NONE = 'none';
13
14const terminalColumns = () => {
15 const {env, stdout, stderr} = process;
16
17 if (stdout?.columns) {
18 return stdout.columns;
19 }
20
21 if (stderr?.columns) {
22 return stderr.columns;
23 }
24
25 if (env.COLUMNS) {
26 return Number.parseInt(env.COLUMNS, 10);
27 }
28
29 return 80;
30};
31
32const getObject = detail => typeof detail === 'number' ? {
33 top: detail,
34 right: detail * 3,
35 bottom: detail,
36 left: detail * 3,
37} : {
38 top: 0,
39 right: 0,
40 bottom: 0,
41 left: 0,
42 ...detail,
43};
44
45const getBorderWidth = borderStyle => borderStyle === NONE ? 0 : 2;
46
47const getBorderChars = borderStyle => {
48 const sides = [
49 'topLeft',
50 'topRight',
51 'bottomRight',
52 'bottomLeft',
53 'left',
54 'right',
55 'top',
56 'bottom',
57 ];
58
59 let characters;
60
61 // Create empty border style
62 if (borderStyle === NONE) {
63 borderStyle = {};
64 for (const side of sides) {
65 borderStyle[side] = '';
66 }
67 }
68
69 if (typeof borderStyle === 'string') {
70 characters = cliBoxes[borderStyle];
71
72 if (!characters) {
73 throw new TypeError(`Invalid border style: ${borderStyle}`);
74 }
75 } else {
76 // Ensure retro-compatibility
77 if (typeof borderStyle?.vertical === 'string') {
78 borderStyle.left = borderStyle.vertical;
79 borderStyle.right = borderStyle.vertical;
80 }
81
82 // Ensure retro-compatibility
83 if (typeof borderStyle?.horizontal === 'string') {
84 borderStyle.top = borderStyle.horizontal;
85 borderStyle.bottom = borderStyle.horizontal;
86 }
87
88 for (const side of sides) {
89 if (borderStyle[side] === null || typeof borderStyle[side] !== 'string') {
90 throw new TypeError(`Invalid border style: ${side}`);
91 }
92 }
93
94 characters = borderStyle;
95 }
96
97 return characters;
98};
99
100const makeTitle = (text, horizontal, alignment) => {
101 let title = '';
102
103 const textWidth = stringWidth(text);
104
105 switch (alignment) {
106 case 'left': {
107 title = text + horizontal.slice(textWidth);
108 break;
109 }
110
111 case 'right': {
112 title = horizontal.slice(textWidth) + text;
113 break;
114 }
115
116 default: {
117 horizontal = horizontal.slice(textWidth);
118
119 if (horizontal.length % 2 === 1) { // This is needed in case the length is odd
120 horizontal = horizontal.slice(Math.floor(horizontal.length / 2));
121 title = horizontal.slice(1) + text + horizontal; // We reduce the left part of one character to avoid the bar to go beyond its limit
122 } else {
123 horizontal = horizontal.slice(horizontal.length / 2);
124 title = horizontal + text + horizontal;
125 }
126
127 break;
128 }
129 }
130
131 return title;
132};
133
134const makeContentText = (text, {padding, width, textAlignment, height}) => {
135 text = ansiAlign(text, {align: textAlignment});
136 let lines = text.split(NEWLINE);
137 const textWidth = widestLine(text);
138
139 const max = width - padding.left - padding.right;
140
141 if (textWidth > max) {
142 const newLines = [];
143 for (const line of lines) {
144 const createdLines = wrapAnsi(line, max, {hard: true});
145 const alignedLines = ansiAlign(createdLines, {align: textAlignment});
146 const alignedLinesArray = alignedLines.split('\n');
147 const longestLength = Math.max(...alignedLinesArray.map(s => stringWidth(s)));
148
149 for (const alignedLine of alignedLinesArray) {
150 let paddedLine;
151 switch (textAlignment) {
152 case 'center': {
153 paddedLine = PAD.repeat((max - longestLength) / 2) + alignedLine;
154 break;
155 }
156
157 case 'right': {
158 paddedLine = PAD.repeat(max - longestLength) + alignedLine;
159 break;
160 }
161
162 default: {
163 paddedLine = alignedLine;
164 break;
165 }
166 }
167
168 newLines.push(paddedLine);
169 }
170 }
171
172 lines = newLines;
173 }
174
175 if (textAlignment === 'center' && textWidth < max) {
176 lines = lines.map(line => PAD.repeat((max - textWidth) / 2) + line);
177 } else if (textAlignment === 'right' && textWidth < max) {
178 lines = lines.map(line => PAD.repeat(max - textWidth) + line);
179 }
180
181 const paddingLeft = PAD.repeat(padding.left);
182 const paddingRight = PAD.repeat(padding.right);
183
184 lines = lines.map(line => paddingLeft + line + paddingRight);
185
186 lines = lines.map(line => {
187 if (width - stringWidth(line) > 0) {
188 switch (textAlignment) {
189 case 'center': {
190 return line + PAD.repeat(width - stringWidth(line));
191 }
192
193 case 'right': {
194 return line + PAD.repeat(width - stringWidth(line));
195 }
196
197 default: {
198 return line + PAD.repeat(width - stringWidth(line));
199 }
200 }
201 }
202
203 return line;
204 });
205
206 if (padding.top > 0) {
207 lines = [...Array.from({length: padding.top}).fill(PAD.repeat(width)), ...lines];
208 }
209
210 if (padding.bottom > 0) {
211 lines = [...lines, ...Array.from({length: padding.bottom}).fill(PAD.repeat(width))];
212 }
213
214 if (height && lines.length > height) {
215 lines = lines.slice(0, height);
216 } else if (height && lines.length < height) {
217 lines = [...lines, ...Array.from({length: height - lines.length}).fill(PAD.repeat(width))];
218 }
219
220 return lines.join(NEWLINE);
221};
222
223const boxContent = (content, contentWidth, options) => {
224 const colorizeBorder = border => {
225 const newBorder = options.borderColor ? getColorFn(options.borderColor)(border) : border;
226 return options.dimBorder ? chalk.dim(newBorder) : newBorder;
227 };
228
229 const colorizeContent = content => options.backgroundColor ? getBGColorFn(options.backgroundColor)(content) : content;
230
231 const chars = getBorderChars(options.borderStyle);
232 const columns = terminalColumns();
233 let marginLeft = PAD.repeat(options.margin.left);
234
235 if (options.float === 'center') {
236 const marginWidth = Math.max((columns - contentWidth - getBorderWidth(options.borderStyle)) / 2, 0);
237 marginLeft = PAD.repeat(marginWidth);
238 } else if (options.float === 'right') {
239 const marginWidth = Math.max(columns - contentWidth - options.margin.right - getBorderWidth(options.borderStyle), 0);
240 marginLeft = PAD.repeat(marginWidth);
241 }
242
243 let result = '';
244
245 if (options.margin.top) {
246 result += NEWLINE.repeat(options.margin.top);
247 }
248
249 if (options.borderStyle !== NONE || options.title) {
250 result += colorizeBorder(marginLeft + chars.topLeft + (options.title ? makeTitle(options.title, chars.top.repeat(contentWidth), options.titleAlignment) : chars.top.repeat(contentWidth)) + chars.topRight) + NEWLINE;
251 }
252
253 const lines = content.split(NEWLINE);
254
255 result += lines.map(line => marginLeft + colorizeBorder(chars.left) + colorizeContent(line) + colorizeBorder(chars.right)).join(NEWLINE);
256
257 if (options.borderStyle !== NONE) {
258 result += NEWLINE + colorizeBorder(marginLeft + chars.bottomLeft + chars.bottom.repeat(contentWidth) + chars.bottomRight);
259 }
260
261 if (options.margin.bottom) {
262 result += NEWLINE.repeat(options.margin.bottom);
263 }
264
265 return result;
266};
267
268const sanitizeOptions = options => {
269 // If fullscreen is enabled, max-out unspecified width/height
270 if (options.fullscreen && process?.stdout) {
271 let newDimensions = [process.stdout.columns, process.stdout.rows];
272
273 if (typeof options.fullscreen === 'function') {
274 newDimensions = options.fullscreen(...newDimensions);
275 }
276
277 if (!options.width) {
278 options.width = newDimensions[0];
279 }
280
281 if (!options.height) {
282 options.height = newDimensions[1];
283 }
284 }
285
286 // If width is provided, make sure it's not below 1
287 if (options.width) {
288 options.width = Math.max(1, options.width - getBorderWidth(options.borderStyle));
289 }
290
291 // If height is provided, make sure it's not below 1
292 if (options.height) {
293 options.height = Math.max(1, options.height - getBorderWidth(options.borderStyle));
294 }
295
296 return options;
297};
298
299const formatTitle = (title, borderStyle) => borderStyle === NONE ? title : ` ${title} `;
300
301const determineDimensions = (text, options) => {
302 options = sanitizeOptions(options);
303 const widthOverride = options.width !== undefined;
304 const columns = terminalColumns();
305 const borderWidth = getBorderWidth(options.borderStyle);
306 const maxWidth = columns - options.margin.left - options.margin.right - borderWidth;
307
308 const widest = widestLine(wrapAnsi(text, columns - borderWidth, {hard: true, trim: false})) + options.padding.left + options.padding.right;
309
310 // If title and width are provided, title adheres to fixed width
311 if (options.title && widthOverride) {
312 options.title = options.title.slice(0, Math.max(0, options.width - 2));
313 if (options.title) {
314 options.title = formatTitle(options.title, options.borderStyle);
315 }
316 } else if (options.title) {
317 options.title = options.title.slice(0, Math.max(0, maxWidth - 2));
318
319 // Recheck if title isn't empty now
320 if (options.title) {
321 options.title = formatTitle(options.title, options.borderStyle);
322 // If the title is larger than content, box adheres to title width
323 if (stringWidth(options.title) > widest) {
324 options.width = stringWidth(options.title);
325 }
326 }
327 }
328
329 // If fixed width is provided, use it or content width as reference
330 options.width = options.width ? options.width : widest;
331
332 if (!widthOverride) {
333 if ((options.margin.left && options.margin.right) && options.width > maxWidth) {
334 // Let's assume we have margins: left = 3, right = 5, in total = 8
335 const spaceForMargins = columns - options.width - borderWidth;
336 // Let's assume we have space = 4
337 const multiplier = spaceForMargins / (options.margin.left + options.margin.right);
338 // Here: multiplier = 4/8 = 0.5
339 options.margin.left = Math.max(0, Math.floor(options.margin.left * multiplier));
340 options.margin.right = Math.max(0, Math.floor(options.margin.right * multiplier));
341 // Left: 3 * 0.5 = 1.5 -> 1
342 // Right: 6 * 0.5 = 3
343 }
344
345 // Re-cap width considering the margins after shrinking
346 options.width = Math.min(options.width, columns - borderWidth - options.margin.left - options.margin.right);
347 }
348
349 // Prevent padding overflow
350 if (options.width - (options.padding.left + options.padding.right) <= 0) {
351 options.padding.left = 0;
352 options.padding.right = 0;
353 }
354
355 if (options.height && options.height - (options.padding.top + options.padding.bottom) <= 0) {
356 options.padding.top = 0;
357 options.padding.bottom = 0;
358 }
359
360 return options;
361};
362
363const isHex = color => color.match(/^#(?:[0-f]{3}){1,2}$/i);
364const isColorValid = color => typeof color === 'string' && (chalk[color] ?? isHex(color));
365const getColorFn = color => isHex(color) ? chalk.hex(color) : chalk[color];
366const getBGColorFn = color => isHex(color) ? chalk.bgHex(color) : chalk[camelCase(['bg', color])];
367
368export default function boxen(text, options) {
369 options = {
370 padding: 0,
371 borderStyle: 'single',
372 dimBorder: false,
373 textAlignment: 'left',
374 float: 'left',
375 titleAlignment: 'left',
376 ...options,
377 };
378
379 // This option is deprecated
380 if (options.align) {
381 options.textAlignment = options.align;
382 }
383
384 if (options.borderColor && !isColorValid(options.borderColor)) {
385 throw new Error(`${options.borderColor} is not a valid borderColor`);
386 }
387
388 if (options.backgroundColor && !isColorValid(options.backgroundColor)) {
389 throw new Error(`${options.backgroundColor} is not a valid backgroundColor`);
390 }
391
392 options.padding = getObject(options.padding);
393 options.margin = getObject(options.margin);
394
395 options = determineDimensions(text, options);
396
397 text = makeContentText(text, options);
398
399 return boxContent(text, options.width, options);
400}
401
402export {default as _borderStyles} from 'cli-boxes';