UNPKG

8.36 kBJavaScriptView Raw
1import process from 'node:process';
2import chalk from 'chalk';
3import cliCursor from 'cli-cursor';
4import cliSpinners from 'cli-spinners';
5import logSymbols from 'log-symbols';
6import stripAnsi from 'strip-ansi';
7import wcwidth from 'wcwidth';
8import isInteractive from 'is-interactive';
9import isUnicodeSupported from 'is-unicode-supported';
10import {StdinDiscarder} from './utilities.js';
11
12let stdinDiscarder;
13
14class Ora {
15 #linesToClear = 0;
16 #isDiscardingStdin = false;
17 #lineCount = 0;
18 #frameIndex = 0;
19 #options;
20 #spinner;
21 #stream;
22 #id;
23 #initialInterval;
24 #isEnabled;
25 #isSilent;
26 #indent;
27 #text;
28 #prefixText;
29
30 color;
31
32 constructor(options) {
33 if (!stdinDiscarder) {
34 stdinDiscarder = new StdinDiscarder();
35 }
36
37 if (typeof options === 'string') {
38 options = {
39 text: options,
40 };
41 }
42
43 this.#options = {
44 color: 'cyan',
45 stream: process.stderr,
46 discardStdin: true,
47 hideCursor: true,
48 ...options,
49 };
50
51 // Public
52 this.color = this.#options.color;
53
54 // It's important that these use the public setters.
55 this.spinner = this.#options.spinner;
56
57 this.#initialInterval = this.#options.interval;
58 this.#stream = this.#options.stream;
59 this.#isEnabled = typeof this.#options.isEnabled === 'boolean' ? this.#options.isEnabled : isInteractive({stream: this.#stream});
60 this.#isSilent = typeof this.#options.isSilent === 'boolean' ? this.#options.isSilent : false;
61
62 // Set *after* `this.#stream`.
63 // It's important that these use the public setters.
64 this.text = this.#options.text;
65 this.prefixText = this.#options.prefixText;
66 this.indent = this.#options.indent;
67
68 if (process.env.NODE_ENV === 'test') {
69 this._stream = this.#stream;
70 this._isEnabled = this.#isEnabled;
71
72 Object.defineProperty(this, '_linesToClear', {
73 get() {
74 return this.#linesToClear;
75 },
76 set(newValue) {
77 this.#linesToClear = newValue;
78 },
79 });
80
81 Object.defineProperty(this, '_frameIndex', {
82 get() {
83 return this.#frameIndex;
84 },
85 });
86
87 Object.defineProperty(this, '_lineCount', {
88 get() {
89 return this.#lineCount;
90 },
91 });
92 }
93 }
94
95 get indent() {
96 return this.#indent;
97 }
98
99 set indent(indent = 0) {
100 if (!(indent >= 0 && Number.isInteger(indent))) {
101 throw new Error('The `indent` option must be an integer from 0 and up');
102 }
103
104 this.#indent = indent;
105 this.updateLineCount();
106 }
107
108 get interval() {
109 return this.#initialInterval || this.#spinner.interval || 100;
110 }
111
112 get spinner() {
113 return this.#spinner;
114 }
115
116 set spinner(spinner) {
117 this.#frameIndex = 0;
118 this.#initialInterval = undefined;
119
120 if (typeof spinner === 'object') {
121 if (spinner.frames === undefined) {
122 throw new Error('The given spinner must have a `frames` property');
123 }
124
125 this.#spinner = spinner;
126 } else if (!isUnicodeSupported()) {
127 this.#spinner = cliSpinners.line;
128 } else if (spinner === undefined) {
129 // Set default spinner
130 this.#spinner = cliSpinners.dots;
131 } else if (spinner !== 'default' && cliSpinners[spinner]) {
132 this.#spinner = cliSpinners[spinner];
133 } else {
134 throw new Error(`There is no built-in spinner named '${spinner}'. See https://github.com/sindresorhus/cli-spinners/blob/main/spinners.json for a full list.`);
135 }
136 }
137
138 get text() {
139 return this.#text;
140 }
141
142 set text(value) {
143 this.#text = value || '';
144 this.updateLineCount();
145 }
146
147 get prefixText() {
148 return this.#prefixText;
149 }
150
151 set prefixText(value) {
152 this.#prefixText = value || '';
153 this.updateLineCount();
154 }
155
156 get isSpinning() {
157 return this.#id !== undefined;
158 }
159
160 // TODO: Use private methods when targeting Node.js 14.
161 getFullPrefixText(prefixText = this.#prefixText, postfix = ' ') {
162 if (typeof prefixText === 'string' && prefixText !== '') {
163 return prefixText + postfix;
164 }
165
166 if (typeof prefixText === 'function') {
167 return prefixText() + postfix;
168 }
169
170 return '';
171 }
172
173 updateLineCount() {
174 const columns = this.#stream.columns || 80;
175 const fullPrefixText = this.getFullPrefixText(this.#prefixText, '-');
176
177 this.#lineCount = 0;
178 for (const line of stripAnsi(' '.repeat(this.#indent) + fullPrefixText + '--' + this.#text).split('\n')) {
179 this.#lineCount += Math.max(1, Math.ceil(wcwidth(line) / columns));
180 }
181 }
182
183 get isEnabled() {
184 return this.#isEnabled && !this.#isSilent;
185 }
186
187 set isEnabled(value) {
188 if (typeof value !== 'boolean') {
189 throw new TypeError('The `isEnabled` option must be a boolean');
190 }
191
192 this.#isEnabled = value;
193 }
194
195 get isSilent() {
196 return this.#isSilent;
197 }
198
199 set isSilent(value) {
200 if (typeof value !== 'boolean') {
201 throw new TypeError('The `isSilent` option must be a boolean');
202 }
203
204 this.#isSilent = value;
205 }
206
207 frame() {
208 const {frames} = this.#spinner;
209 let frame = frames[this.#frameIndex];
210
211 if (this.color) {
212 frame = chalk[this.color](frame);
213 }
214
215 this.#frameIndex = ++this.#frameIndex % frames.length;
216 const fullPrefixText = (typeof this.#prefixText === 'string' && this.#prefixText !== '') ? this.#prefixText + ' ' : '';
217 const fullText = typeof this.text === 'string' ? ' ' + this.text : '';
218
219 return fullPrefixText + frame + fullText;
220 }
221
222 clear() {
223 if (!this.#isEnabled || !this.#stream.isTTY) {
224 return this;
225 }
226
227 this.#stream.cursorTo(0);
228
229 for (let index = 0; index < this.#linesToClear; index++) {
230 if (index > 0) {
231 this.#stream.moveCursor(0, -1);
232 }
233
234 this.#stream.clearLine(1);
235 }
236
237 if (this.#indent || this.lastIndent !== this.#indent) {
238 this.#stream.cursorTo(this.#indent);
239 }
240
241 this.lastIndent = this.#indent;
242 this.#linesToClear = 0;
243
244 return this;
245 }
246
247 render() {
248 if (this.#isSilent) {
249 return this;
250 }
251
252 this.clear();
253 this.#stream.write(this.frame());
254 this.#linesToClear = this.#lineCount;
255
256 return this;
257 }
258
259 start(text) {
260 if (text) {
261 this.text = text;
262 }
263
264 if (this.#isSilent) {
265 return this;
266 }
267
268 if (!this.#isEnabled) {
269 if (this.text) {
270 this.#stream.write(`- ${this.text}\n`);
271 }
272
273 return this;
274 }
275
276 if (this.isSpinning) {
277 return this;
278 }
279
280 if (this.#options.hideCursor) {
281 cliCursor.hide(this.#stream);
282 }
283
284 if (this.#options.discardStdin && process.stdin.isTTY) {
285 this.#isDiscardingStdin = true;
286 stdinDiscarder.start();
287 }
288
289 this.render();
290 this.#id = setInterval(this.render.bind(this), this.interval);
291
292 return this;
293 }
294
295 stop() {
296 if (!this.#isEnabled) {
297 return this;
298 }
299
300 clearInterval(this.#id);
301 this.#id = undefined;
302 this.#frameIndex = 0;
303 this.clear();
304 if (this.#options.hideCursor) {
305 cliCursor.show(this.#stream);
306 }
307
308 if (this.#options.discardStdin && process.stdin.isTTY && this.#isDiscardingStdin) {
309 stdinDiscarder.stop();
310 this.#isDiscardingStdin = false;
311 }
312
313 return this;
314 }
315
316 succeed(text) {
317 return this.stopAndPersist({symbol: logSymbols.success, text});
318 }
319
320 fail(text) {
321 return this.stopAndPersist({symbol: logSymbols.error, text});
322 }
323
324 warn(text) {
325 return this.stopAndPersist({symbol: logSymbols.warning, text});
326 }
327
328 info(text) {
329 return this.stopAndPersist({symbol: logSymbols.info, text});
330 }
331
332 stopAndPersist(options = {}) {
333 if (this.#isSilent) {
334 return this;
335 }
336
337 const prefixText = options.prefixText || this.#prefixText;
338 const text = options.text || this.text;
339 const fullText = (typeof text === 'string') ? ' ' + text : '';
340
341 this.stop();
342 this.#stream.write(`${this.getFullPrefixText(prefixText, ' ')}${options.symbol || ' '}${fullText}\n`);
343
344 return this;
345 }
346}
347
348export default function ora(options) {
349 return new Ora(options);
350}
351
352export async function oraPromise(action, options) {
353 const actionIsFunction = typeof action === 'function';
354 const actionIsPromise = typeof action.then === 'function';
355
356 if (!actionIsFunction && !actionIsPromise) {
357 throw new TypeError('Parameter `action` must be a Function or a Promise');
358 }
359
360 const {successText, failText} = typeof options === 'object'
361 ? options
362 : {successText: undefined, failText: undefined};
363
364 const spinner = ora(options).start();
365
366 try {
367 const promise = actionIsFunction ? action(spinner) : action;
368 const result = await promise;
369
370 spinner.succeed(
371 successText === undefined
372 ? undefined
373 : (typeof successText === 'string' ? successText : successText(result)),
374 );
375
376 return result;
377 } catch (error) {
378 spinner.fail(
379 failText === undefined
380 ? undefined
381 : (typeof failText === 'string' ? failText : failText(error)),
382 );
383
384 throw error;
385 }
386}