1 | import process from 'node:process';
|
2 | import stringWidth from 'string-width';
|
3 | import chalk from 'chalk';
|
4 | import widestLine from 'widest-line';
|
5 | import cliBoxes from 'cli-boxes';
|
6 | import camelCase from 'camelcase';
|
7 | import ansiAlign from 'ansi-align';
|
8 | import wrapAnsi from 'wrap-ansi';
|
9 |
|
10 | const NEWLINE = '\n';
|
11 | const PAD = ' ';
|
12 |
|
13 | const 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 |
|
31 | const 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 |
|
44 | const 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 |
|
75 | const 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) {
|
91 | horizontal = horizontal.slice(Math.floor(horizontal.length / 2));
|
92 | title = horizontal.slice(1) + text + horizontal;
|
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 |
|
104 | const 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 |
|
177 | const isHex = color => color.match(/^#(?:[0-f]{3}){1,2}$/i);
|
178 | const isColorValid = color => typeof color === 'string' && ((chalk[color]) || isHex(color));
|
179 | const getColorFn = color => isHex(color) ? chalk.hex(color) : chalk[color];
|
180 | const getBGColorFn = color => isHex(color) ? chalk.bgHex(color) : chalk[camelCase(['bg', color])];
|
181 |
|
182 | export 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 |
|
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 |
|
224 | let title = options.title && options.title.slice(0, columns - 4 - margin.left - margin.right);
|
225 |
|
226 | if (title) {
|
227 | title = ` ${title} `;
|
228 |
|
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 |
|
236 | const spaceForMargins = columns - contentWidth - BORDERS_WIDTH;
|
237 |
|
238 | const multiplier = spaceForMargins / (margin.left + margin.right);
|
239 |
|
240 | margin.left = Math.max(0, Math.floor(margin.left * multiplier));
|
241 | margin.right = Math.max(0, Math.floor(margin.right * multiplier));
|
242 |
|
243 |
|
244 | }
|
245 |
|
246 |
|
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 |
|
275 | export const _borderStyles = cliBoxes;
|