initial revival
This commit is contained in:
commit
89c29a54ee
17 changed files with 1422 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
node_modules
|
18
LICENSE
Normal file
18
LICENSE
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
Copyright 2025 Chloe Caruso
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||||
|
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
16
README.md
Normal file
16
README.md
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
# `@paperclover/console`
|
||||||
|
|
||||||
|
Provides pretty console logging functions as well as a widget system including a
|
||||||
|
progress bar and spinner.
|
||||||
|
|
||||||
|
Examples are available in the `examples` directory.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
The package is distributed as TypeScript source code through this Git
|
||||||
|
repository. You can install it with a JavaScript package manager such as `bun`.
|
||||||
|
Most package managers should pin the latest commit.
|
||||||
|
|
||||||
|
```
|
||||||
|
bun add git+https://git.paperclover.net/clo/console.git
|
||||||
|
```
|
37
bun.lock
Normal file
37
bun.lock
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "@paperclover/console",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-escapes": "^7.0.0",
|
||||||
|
"chalk": "^5.4.1",
|
||||||
|
"strip-ansi": "^7.1.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "1.2.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/bun": ["@types/bun@1.2.5", "", { "dependencies": { "bun-types": "1.2.5" } }, "sha512-w2OZTzrZTVtbnJew1pdFmgV99H0/L+Pvw+z1P67HaR18MHOzYnTYOi6qzErhK8HyT+DB782ADVPPE92Xu2/Opg=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="],
|
||||||
|
|
||||||
|
"@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="],
|
||||||
|
|
||||||
|
"ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="],
|
||||||
|
|
||||||
|
"ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.2.5", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-3oO6LVGGRRKI4kHINx5PIdIgnLRb7l/SprhzqXapmoYkFl5m4j6EvALvbDVuuBFaamB46Ap6HCUxIXNLCGy+tg=="],
|
||||||
|
|
||||||
|
"chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="],
|
||||||
|
|
||||||
|
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
|
||||||
|
|
||||||
|
"strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||||
|
}
|
||||||
|
}
|
10
examples/basic.ts
Normal file
10
examples/basic.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import * as console from '@paperclover/console';
|
||||||
|
|
||||||
|
console.info('Hello, world!');
|
||||||
|
console.warn('This is a warning');
|
||||||
|
console.error('This is an error');
|
||||||
|
console.debug('This is a debug message');
|
||||||
|
console.success('This is a success message');
|
||||||
|
|
||||||
|
const custom = console.scoped('my_own');
|
||||||
|
custom('Hello, world!');
|
6
examples/inject.ts
Normal file
6
examples/inject.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import '@paperclover/console/inject';
|
||||||
|
|
||||||
|
console.info('Hello, world!');
|
||||||
|
console.warn('This is a warning');
|
||||||
|
console.error('This is an error');
|
||||||
|
console.debug('This is a debug message');
|
11
examples/progress-bar.ts
Normal file
11
examples/progress-bar.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { withProgress } from "@paperclover/console/Progress";
|
||||||
|
|
||||||
|
await withProgress('do a task', async (progress) => {
|
||||||
|
// mutate the progress object
|
||||||
|
progress.total = 100;
|
||||||
|
|
||||||
|
for (let i = 0; i < progress.total; i++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
progress.value += 1;
|
||||||
|
}
|
||||||
|
});
|
34
examples/spinner.ts
Normal file
34
examples/spinner.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { Spinner } from "@paperclover/console/Spinner";
|
||||||
|
|
||||||
|
const first = new Spinner({
|
||||||
|
text: 'Spinner 1: ',
|
||||||
|
color: 'blueBright',
|
||||||
|
});
|
||||||
|
|
||||||
|
const second = new Spinner({
|
||||||
|
text: () => `Spinner 2: ${random()}`,
|
||||||
|
color: 'blueBright',
|
||||||
|
});
|
||||||
|
second.fps = 30;
|
||||||
|
|
||||||
|
const third = new Spinner<{ value: string }>({
|
||||||
|
text: ({ value }) => `Spinner 3: ${value}`,
|
||||||
|
color: 'blueBright',
|
||||||
|
});
|
||||||
|
third.fps = 4;
|
||||||
|
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
first.text = `Spinner 1: ${random()}`;
|
||||||
|
if (i === 20) {
|
||||||
|
second.success('second done!');
|
||||||
|
}
|
||||||
|
third.update({ value: random() });
|
||||||
|
}
|
||||||
|
|
||||||
|
first.success('first done!');
|
||||||
|
// third.success('third done!');
|
||||||
|
|
||||||
|
function random() {
|
||||||
|
return Math.random().toString(36).substring(2, 15);
|
||||||
|
}
|
20
package.json
Normal file
20
package.json
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"name": "@paperclover/console",
|
||||||
|
"main": "index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/console.ts",
|
||||||
|
"./Spinner": "./src/Spinner.ts",
|
||||||
|
"./Progress": "./src/Progress.ts",
|
||||||
|
"./Widget": "./src/Widget.ts",
|
||||||
|
"./inject": "./src/inject.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "1.2.5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-escapes": "^7.0.0",
|
||||||
|
"chalk": "^5.4.1",
|
||||||
|
"strip-ansi": "^7.1.0"
|
||||||
|
}
|
||||||
|
}
|
297
src/Progress.ts
Normal file
297
src/Progress.ts
Normal file
|
@ -0,0 +1,297 @@
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { convertHSVtoRGB, getColor, type CustomLoggerColor } from './internal.ts';
|
||||||
|
import { defaultSpinnerOptions } from './Spinner.ts';
|
||||||
|
import { isUnicodeSupported } from './console.ts';
|
||||||
|
import { Widget } from './Widget.ts';
|
||||||
|
|
||||||
|
const boxChars = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
|
||||||
|
const fullBox = '█';
|
||||||
|
const asciiChars = {
|
||||||
|
start: '[',
|
||||||
|
end: ']',
|
||||||
|
empty: ' ',
|
||||||
|
fill: '=',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is derived from an old program I wrote back in 2020 called `f` which did ffmpeg
|
||||||
|
* handling. It is probably one of the coolest progress bars ever imagined. The original had the HSL
|
||||||
|
* colors baked in, but this one doesn't do that.
|
||||||
|
*
|
||||||
|
* For those interested:
|
||||||
|
* https://github.com/paperdave/f/blob/fcc418f11c7abe979ec01d90d5fdf7e50fb6ec25/src/render.ts.
|
||||||
|
*/
|
||||||
|
function getUnicodeBar(progress: number, width: number) {
|
||||||
|
if (progress >= 1) {
|
||||||
|
return fullBox.repeat(width);
|
||||||
|
}
|
||||||
|
if (progress <= 0 || isNaN(progress)) {
|
||||||
|
return ' '.repeat(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wholeWidth = Math.floor(progress * width);
|
||||||
|
const remainderWidth = (progress * width) % 1;
|
||||||
|
const partWidth = Math.floor(remainderWidth * 8);
|
||||||
|
let partChar = boxChars[partWidth];
|
||||||
|
if (width - wholeWidth - 1 < 0) {
|
||||||
|
partChar = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fill = fullBox.repeat(wholeWidth);
|
||||||
|
const empty = ' '.repeat(width - wholeWidth - 1);
|
||||||
|
|
||||||
|
return `${fill}${partChar}${empty}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get an ascii progress bar. Boring. */
|
||||||
|
function getAsciiBar(progress: number, width: number) {
|
||||||
|
return [
|
||||||
|
asciiChars.start,
|
||||||
|
asciiChars.fill.repeat(Math.floor(progress * (width - 2))),
|
||||||
|
asciiChars.empty.repeat(width - Math.ceil(progress * (width - 2))),
|
||||||
|
asciiChars.end,
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Progress Bar Style. Ascii is forced in non-unicode terminals. */
|
||||||
|
export type BarStyle = 'unicode' | 'ascii';
|
||||||
|
|
||||||
|
/** Options to be passed to `new Progress` */
|
||||||
|
export interface ProgressOptions<Props extends Record<string, unknown> = Record<never, unknown>> {
|
||||||
|
/** Text displayed to the right of the bar. */
|
||||||
|
text: string | ((props: ExtendedProps<Props>) => string);
|
||||||
|
/** Text displayed to the left of the bar, if specified. */
|
||||||
|
beforeText?: string | ((props: ExtendedProps<Props>) => string);
|
||||||
|
/** Properties to be passed to `text` and `beforeText` formatting functions. */
|
||||||
|
props?: Props;
|
||||||
|
/** Width of the progress bar itself. Default: 35. */
|
||||||
|
barWidth?: number;
|
||||||
|
/** Progress bar style, default `BarStyle.Unicode` */
|
||||||
|
barStyle?: BarStyle;
|
||||||
|
/** Spinner settings. Set to `null` to disable the spinner. */
|
||||||
|
spinner?: Partial<BarSpinnerOptions> | null;
|
||||||
|
/** Starting value. Default: 0. */
|
||||||
|
value?: number;
|
||||||
|
/** Ending value. Default: 100. */
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarSpinnerOptions {
|
||||||
|
/** Frames per second of the Spinner. */
|
||||||
|
fps: number;
|
||||||
|
/** Sequence of frames for the spinner. */
|
||||||
|
frames: string[];
|
||||||
|
/** Color of the spinner. If set to `match` it will match the bar. */
|
||||||
|
color: CustomLoggerColor | 'match';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
beforeText: '',
|
||||||
|
barWidth: 35,
|
||||||
|
barColor: 'rgb',
|
||||||
|
barStyle: 'unicode',
|
||||||
|
spinner: {
|
||||||
|
...defaultSpinnerOptions,
|
||||||
|
color: 'match',
|
||||||
|
},
|
||||||
|
value: 0,
|
||||||
|
total: 100,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type ExtendedProps<T> = T & {
|
||||||
|
value: number;
|
||||||
|
total: number;
|
||||||
|
/** Number 0-1, inclusive. */
|
||||||
|
progress: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Progress<Props extends Record<string, unknown> = Record<never, unknown>> extends Widget {
|
||||||
|
#text: string | ((props: ExtendedProps<Props>) => string);
|
||||||
|
#beforeText: string | ((props: ExtendedProps<Props>) => string);
|
||||||
|
#barWidth: number;
|
||||||
|
#barStyle: NonNullable<ProgressOptions['barStyle']>;
|
||||||
|
#spinnerColor: NonNullable<BarSpinnerOptions['color']>;
|
||||||
|
#spinnerFrames?: readonly string[];
|
||||||
|
#props: Props;
|
||||||
|
#spinnerFPS: number;
|
||||||
|
#value: number;
|
||||||
|
#total: number;
|
||||||
|
fps: number;
|
||||||
|
|
||||||
|
constructor(options: ProgressOptions<Props> | string) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
if (typeof options === 'string') {
|
||||||
|
options = { text: options };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#text = options.text;
|
||||||
|
this.#beforeText = options.beforeText ?? defaultOptions.beforeText;
|
||||||
|
this.#barWidth = options.barWidth ?? defaultOptions.barWidth;
|
||||||
|
this.#barStyle = options.barStyle ?? defaultOptions.barStyle;
|
||||||
|
this.#props = options.props ?? ({} as Props);
|
||||||
|
this.#value = options.value ?? defaultOptions.value;
|
||||||
|
this.#total = options.total ?? defaultOptions.total;
|
||||||
|
|
||||||
|
// Undefined will trigger the "no spinner"
|
||||||
|
// eslint-disable-next-line eqeqeq
|
||||||
|
if (options.spinner !== null) {
|
||||||
|
this.fps = 15;
|
||||||
|
this.#spinnerFPS = options.spinner?.fps ?? defaultOptions.spinner.fps;
|
||||||
|
this.#spinnerFrames = options.spinner?.frames ?? defaultOptions.spinner.frames;
|
||||||
|
this.#spinnerColor = options.spinner?.color ?? defaultOptions.spinner.color;
|
||||||
|
} else {
|
||||||
|
this.fps = 0;
|
||||||
|
this.#spinnerFPS = defaultOptions.spinner.fps;
|
||||||
|
this.#spinnerFrames = undefined;
|
||||||
|
this.#spinnerColor = defaultOptions.spinner.color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Properties to be passed to `text` and `beforeText` formatting functions. */
|
||||||
|
set props(value: Partial<Props>) {
|
||||||
|
this.#props = {
|
||||||
|
...this.#props,
|
||||||
|
...value,
|
||||||
|
};
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): ExtendedProps<Props> {
|
||||||
|
return {
|
||||||
|
...this.#props,
|
||||||
|
value: this.#value,
|
||||||
|
total: this.#total,
|
||||||
|
progress: this.#total === 0 ? 1 : this.#value / this.#total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text displayed to the right of the bar. */
|
||||||
|
get text(): string {
|
||||||
|
return typeof this.#text === 'function' ? this.#text(this.props) : this.#text;
|
||||||
|
}
|
||||||
|
|
||||||
|
set text(value: string | (() => string)) {
|
||||||
|
this.#text = value;
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text displayed to the left of the bar, if specified. */
|
||||||
|
get beforeText(): string {
|
||||||
|
return typeof this.#beforeText === 'function' ? this.#beforeText(this.props) : this.#beforeText;
|
||||||
|
}
|
||||||
|
|
||||||
|
set beforeText(value: string | (() => string)) {
|
||||||
|
this.#beforeText = value;
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
/** Current value of progress bar. */
|
||||||
|
get value() {
|
||||||
|
return this.#value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set value(value: number) {
|
||||||
|
this.#value = value;
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total value of progress bar. When value === total, the bar is full. */
|
||||||
|
get total() {
|
||||||
|
return this.#total;
|
||||||
|
}
|
||||||
|
|
||||||
|
set total(value: number) {
|
||||||
|
this.#total = value;
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates the progress bar with a new value and props. */
|
||||||
|
update(value: number, props?: Partial<Props>) {
|
||||||
|
this.#value = value;
|
||||||
|
if (props) {
|
||||||
|
this.#props = {
|
||||||
|
...this.props,
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected format(now: number): string {
|
||||||
|
const progress = this.#total === 0 ? 1 : this.#value / this.#total;
|
||||||
|
|
||||||
|
const hue = Math.min(Math.max(progress, 0), 1) / 3;
|
||||||
|
const barColor = chalk
|
||||||
|
.rgb(...convertHSVtoRGB(hue, 0.8, 1))
|
||||||
|
.bgRgb(...convertHSVtoRGB(hue, 0.8, 0.5));
|
||||||
|
|
||||||
|
let spinner;
|
||||||
|
if (this.#spinnerFrames) {
|
||||||
|
const frame = Math.floor(now / (1000 / this.#spinnerFPS)) % this.#spinnerFrames.length;
|
||||||
|
spinner = this.#spinnerColor
|
||||||
|
? (this.#spinnerColor === 'match'
|
||||||
|
? chalk.rgb(...convertHSVtoRGB(hue, 0.8, 1))
|
||||||
|
: getColor(this.#spinnerColor))(this.#spinnerFrames[frame])
|
||||||
|
: this.#spinnerFrames[frame];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBar = isUnicodeSupported && this.#barStyle === 'unicode' ? getUnicodeBar : getAsciiBar;
|
||||||
|
|
||||||
|
const beforeText = this.beforeText;
|
||||||
|
|
||||||
|
return [
|
||||||
|
spinner ? spinner + ' ' : '',
|
||||||
|
beforeText ? beforeText + ' ' : '',
|
||||||
|
barColor(getBar(progress, this.#barWidth)),
|
||||||
|
' ',
|
||||||
|
this.text,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
success(message?: string): void {
|
||||||
|
super.success(message ?? this.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message?: string | Error): void {
|
||||||
|
super.error(message ?? this.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithProgressOptions<Props extends Record<string, unknown>, T>
|
||||||
|
extends ProgressOptions<Props> {
|
||||||
|
/** Message to print on success. If a function, the result is passed. */
|
||||||
|
successText?: string | ((result: T) => string);
|
||||||
|
/** Message to print on fail. If a function, the error is passed. */
|
||||||
|
failureText?: string | ((error: Error) => string);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calls a function with a progress bar. */
|
||||||
|
export async function withProgress<Props extends Record<string, unknown>, T>(
|
||||||
|
opts: WithProgressOptions<Props, T> | string,
|
||||||
|
fn: (bar: Progress<Props>) => Promise<T>
|
||||||
|
): Promise<T>;
|
||||||
|
export async function withProgress(opts: any, fn: any) {
|
||||||
|
const bar = new Progress(opts);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fn(bar);
|
||||||
|
bar.success(
|
||||||
|
opts.successText
|
||||||
|
? typeof opts.successText === 'function'
|
||||||
|
? opts.successText(result)
|
||||||
|
: opts.successText
|
||||||
|
: opts.text
|
||||||
|
? typeof opts.text === 'function'
|
||||||
|
? opts.text(bar.props)
|
||||||
|
: opts.text
|
||||||
|
: 'Completed'
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
bar.error(
|
||||||
|
typeof opts.failureText === 'function' ? opts.failureText(error) : opts.failureText ?? error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
139
src/Spinner.ts
Normal file
139
src/Spinner.ts
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
import chalk, { type ChalkInstance } from 'chalk';
|
||||||
|
import { Widget } from './Widget.ts';
|
||||||
|
import { getColor, type CustomLoggerColor } from './internal.ts';
|
||||||
|
|
||||||
|
export interface SpinnerOptions<Props extends Record<string, unknown>> {
|
||||||
|
/** Text displayed to the right of the spinner. */
|
||||||
|
text: string | ((props: Props) => string);
|
||||||
|
/** Color of the spinner. */
|
||||||
|
color?: CustomLoggerColor | false;
|
||||||
|
/** Sequence of frames for the spinner. */
|
||||||
|
frames?: readonly string[];
|
||||||
|
/** Frames per second of the Spinner. */
|
||||||
|
fps?: number;
|
||||||
|
/** Properties to be passed to the `text` formatting function. */
|
||||||
|
props?: Props;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultSpinnerOptions = {
|
||||||
|
text: 'Loading...',
|
||||||
|
color: 'blueBright',
|
||||||
|
frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
||||||
|
fps: 12.5,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export class Spinner<Props extends Record<string, unknown> = Record<string, unknown>> extends Widget {
|
||||||
|
#text: string | ((props: Props) => string);
|
||||||
|
#color: ((text: string) => string) | null;
|
||||||
|
#frames: readonly string[];
|
||||||
|
#props: Props;
|
||||||
|
fps: number;
|
||||||
|
|
||||||
|
constructor(options: SpinnerOptions<Props> | string) {
|
||||||
|
super();
|
||||||
|
if (typeof options === 'string') {
|
||||||
|
options = { text: options };
|
||||||
|
}
|
||||||
|
this.#text = options.text ?? defaultSpinnerOptions.text;
|
||||||
|
const color = options.color ?? defaultSpinnerOptions.color;
|
||||||
|
this.#color = color === false ? null : getColor(color);
|
||||||
|
this.#frames = options.frames ?? defaultSpinnerOptions.frames;
|
||||||
|
this.fps = options.fps ?? defaultSpinnerOptions.fps;
|
||||||
|
this.#props = options.props ?? ({} as Props);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text displayed to the right of the spinner. */
|
||||||
|
get text(): string {
|
||||||
|
return typeof this.#text === 'function' ? this.#text(this.#props) : this.#text;
|
||||||
|
}
|
||||||
|
|
||||||
|
set text(value: string | (() => string)) {
|
||||||
|
this.#text = value;
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Properties to be passed to `text` and `beforeText` formatting functions. */
|
||||||
|
set props(value: Partial<Props>) {
|
||||||
|
this.#props = {
|
||||||
|
...this.#props,
|
||||||
|
...value,
|
||||||
|
};
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): Props {
|
||||||
|
return {
|
||||||
|
...this.#props,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the spinner by supplying either a new `message` string or a partial object of props to
|
||||||
|
* be used by the custom message function.
|
||||||
|
*/
|
||||||
|
update(newProps: Partial<Props>): void;
|
||||||
|
update(newMessage: string): void;
|
||||||
|
update(newData: string | Partial<Props>) {
|
||||||
|
if (typeof newData === 'string') {
|
||||||
|
this.text = newData;
|
||||||
|
} else {
|
||||||
|
this.#props = { ...this.#props, ...newData };
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
format(now: number): string {
|
||||||
|
const frame = Math.floor(now / (1000 / this.fps)) % this.#frames.length;
|
||||||
|
const frameText = this.#frames[frame]!;
|
||||||
|
return (
|
||||||
|
(this.#color ? this.#color(frameText) : frameText) +
|
||||||
|
' ' +
|
||||||
|
this.text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
success(message?: string): void {
|
||||||
|
super.success(message ?? this.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message?: string | Error): void {
|
||||||
|
super.error(message ?? this.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithSpinnerOptions<Props extends Record<string, unknown>, T>
|
||||||
|
extends SpinnerOptions<Props> {
|
||||||
|
successText?: string | ((result: T) => string);
|
||||||
|
failureText?: string | ((error: Error) => string);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calls a function with a spinner. */
|
||||||
|
export async function withSpinner<Props extends Record<string, unknown>, T>(
|
||||||
|
spinnerOptions: WithSpinnerOptions<Props, T> | string,
|
||||||
|
fn: (spinner: Spinner<Props>) => Promise<T>
|
||||||
|
): Promise<T>;
|
||||||
|
export async function withSpinner(opts: any, fn: any) {
|
||||||
|
const spinner = new Spinner(opts);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fn(spinner);
|
||||||
|
if (spinner.active) {
|
||||||
|
spinner.success(
|
||||||
|
opts.successText
|
||||||
|
? typeof opts.successText === 'function'
|
||||||
|
? opts.successText(result)
|
||||||
|
: opts.successText
|
||||||
|
: opts.text
|
||||||
|
? typeof opts.text === 'function'
|
||||||
|
? opts.text(spinner.props)
|
||||||
|
: opts.text
|
||||||
|
: 'Completed'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
spinner.error(
|
||||||
|
typeof opts.failureText === 'function' ? opts.failureText(error) : opts.failureText ?? error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
160
src/Widget.ts
Normal file
160
src/Widget.ts
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
import ansi from 'ansi-escapes';
|
||||||
|
import { error, success } from './console.ts';
|
||||||
|
import { flushStderr, writeToStderr } from './internal.ts';
|
||||||
|
|
||||||
|
const widgets: Widget[] = [];
|
||||||
|
let widgetLineCount = 0;
|
||||||
|
let widgetTimer: Timer | undefined;
|
||||||
|
let redrawingThisTick = false;
|
||||||
|
|
||||||
|
const kInternalUpdate = Symbol('internalUpdate');
|
||||||
|
const kInternalGetText = Symbol('internalGetText');
|
||||||
|
|
||||||
|
export interface Key {
|
||||||
|
sequence?: string;
|
||||||
|
text?: string;
|
||||||
|
name?: string;
|
||||||
|
ctrl: boolean;
|
||||||
|
meta: boolean;
|
||||||
|
shift: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExit() {
|
||||||
|
errorAllWidgets('widget alive while process exiting');
|
||||||
|
writeToStderr(ansi.cursorShow);
|
||||||
|
flushStderr();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Log Widget is a piece of log content that is held at the bottom of the
|
||||||
|
* console log, and can be animated/dynamically updated. It is used to create
|
||||||
|
* spinners, progress bars, and other rich visuals.
|
||||||
|
*/
|
||||||
|
export abstract class Widget {
|
||||||
|
constructor() {
|
||||||
|
widgets.push(this);
|
||||||
|
|
||||||
|
if (!widgetTimer) {
|
||||||
|
writeToStderr(ansi.cursorHide);
|
||||||
|
widgetTimer = setInterval(redrawWidgetsNoWait, 1000 / 60);
|
||||||
|
widgetTimer?.unref?.();
|
||||||
|
process.on('exit', onExit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a string of what the widget looks like. Called 15 times per second
|
||||||
|
* to allow for smooth animation. The value passed to now is the result of
|
||||||
|
* `performance.now`.
|
||||||
|
*/
|
||||||
|
abstract format(now: number): string;
|
||||||
|
/**
|
||||||
|
* The current FPS of the widget. If this is set to 0, the widget will not
|
||||||
|
* automatically update, and you must call `update`.
|
||||||
|
*/
|
||||||
|
abstract fps: number;
|
||||||
|
|
||||||
|
/** Removes this widget from the log. */
|
||||||
|
stop(finalMessage?: string) {
|
||||||
|
const index = widgets.indexOf(this);
|
||||||
|
if (index === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
widgets.splice(index, 1);
|
||||||
|
redrawWidgetsSoon();
|
||||||
|
if (finalMessage) {
|
||||||
|
writeToStderr(finalMessage + '\n');
|
||||||
|
}
|
||||||
|
if (widgets.length === 0) {
|
||||||
|
clearInterval(widgetTimer);
|
||||||
|
process.removeListener('exit', onExit);
|
||||||
|
widgetTimer = undefined;
|
||||||
|
writeToStderr(ansi.cursorShow);
|
||||||
|
redrawWidgetsNoWait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forces a redraw to happen immediately. */
|
||||||
|
protected redraw() {
|
||||||
|
this.#nextUpdate = 0;
|
||||||
|
redrawWidgetsSoon();
|
||||||
|
}
|
||||||
|
|
||||||
|
#nextUpdate = 0;
|
||||||
|
#text = '';
|
||||||
|
#newlines = 0;
|
||||||
|
|
||||||
|
[kInternalUpdate](now: number) {
|
||||||
|
if (now > this.#nextUpdate) {
|
||||||
|
this.#nextUpdate = this.fps === 0 ? Infinity : now + 1000 / this.fps;
|
||||||
|
const text = this.format(now);
|
||||||
|
if (text !== this.#text) {
|
||||||
|
this.#text = text + '\n';
|
||||||
|
this.#newlines = text.split('\n').length;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[kInternalGetText]() {
|
||||||
|
widgetLineCount += this.#newlines;
|
||||||
|
return this.#text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove this widget with a success message. */
|
||||||
|
success(message: string) {
|
||||||
|
success(message);
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove this widget with a failure message. */
|
||||||
|
error(message: string | Error) {
|
||||||
|
error(message);
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
get active() {
|
||||||
|
return widgets.includes(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redrawWidgetsSoon() {
|
||||||
|
if (widgetLineCount) {
|
||||||
|
writeToStderr(
|
||||||
|
ansi.eraseLine + (ansi.cursorUp(1) + ansi.eraseLine).repeat(widgetLineCount) + '\r'
|
||||||
|
);
|
||||||
|
widgetLineCount = 0;
|
||||||
|
redrawingThisTick = true;
|
||||||
|
process.nextTick(redrawWidgetsNoWait);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function redrawWidgetsNoWait() {
|
||||||
|
redrawingThisTick = false;
|
||||||
|
const now = performance.now();
|
||||||
|
const hasUpdate = widgets.filter(widget => widget[kInternalUpdate](now)).length > 0;
|
||||||
|
|
||||||
|
if (hasUpdate || widgetLineCount === 0) {
|
||||||
|
redrawWidgetsSoon();
|
||||||
|
writeToStderr(widgets.map(widget => widget[kInternalGetText]()).join(''));
|
||||||
|
}
|
||||||
|
flushStderr();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorAllWidgets(reason: string) {
|
||||||
|
for (const w of widgets) {
|
||||||
|
if ('text' in w) {
|
||||||
|
w.error((w as any).text + ` (due to ${reason})`);
|
||||||
|
} else {
|
||||||
|
w.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes raw line of text without a prefix or filtering. */
|
||||||
|
export function writeLine(message = '') {
|
||||||
|
redrawWidgetsSoon();
|
||||||
|
writeToStderr(message + '\n');
|
||||||
|
if (!redrawingThisTick) flushStderr();
|
||||||
|
}
|
245
src/console.ts
Normal file
245
src/console.ts
Normal file
|
@ -0,0 +1,245 @@
|
||||||
|
export const isUnicodeSupported =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? Boolean(process.env.CI) ||
|
||||||
|
Boolean(process.env.WT_SESSION) || // Windows Terminal
|
||||||
|
process.env.ConEmuTask === '{cmd::Cmder}' || // ConEmu and cmder
|
||||||
|
process.env.TERM_PROGRAM === 'vscode' ||
|
||||||
|
process.env.TERM === 'xterm-256color' ||
|
||||||
|
process.env.TERM === 'alacritty' ||
|
||||||
|
process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm'
|
||||||
|
: process.env.TERM !== 'linux';
|
||||||
|
|
||||||
|
export const errorSymbol = isUnicodeSupported ? '✖' : 'x';
|
||||||
|
export const successSymbol = isUnicodeSupported ? '✔' : '√';
|
||||||
|
export const infoSymbol = isUnicodeSupported ? 'ℹ' : 'i';
|
||||||
|
export const warningSymbol = isUnicodeSupported ? '⚠' : '‼';
|
||||||
|
|
||||||
|
let filters: string[] = [];
|
||||||
|
let filterGeneration = 0;
|
||||||
|
export function setLogFilter(...newFilters: Array<string | string[]>) {
|
||||||
|
filters = newFilters.flat().map(filter => filter.toLowerCase());
|
||||||
|
filterGeneration++;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLogVisible(id: string, defaultVisibility = true) {
|
||||||
|
for (const filter of filters) {
|
||||||
|
if (filter === '*') {
|
||||||
|
defaultVisibility = true;
|
||||||
|
} else if (filter === '-*') {
|
||||||
|
defaultVisibility = false;
|
||||||
|
} else if (filter === id || id.startsWith(filter + ':')) {
|
||||||
|
defaultVisibility = true;
|
||||||
|
} else if (filter === '-' + id || id.startsWith('-' + filter + ':')) {
|
||||||
|
defaultVisibility = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultVisibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.DEBUG !== undefined) {
|
||||||
|
setLogFilter(
|
||||||
|
String(process.env.DEBUG)
|
||||||
|
.split(',')
|
||||||
|
.map(x => x.trim())
|
||||||
|
.map(x => (['1', 'true', 'all'].includes(x.toLowerCase()) ? '*' : x))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Taken from https://github.com/debug-js/debug/blob/d1616622e4d404863c5a98443f755b4006e971dc/src/node.js#L35. */
|
||||||
|
const debugColors = [
|
||||||
|
20, 21, 26, 27, 32, 33, 38, 39, 40, 41, 42, 43, 44, 45, 56, 57, 62, 63, 68, 69, 74, 75, 76, 77,
|
||||||
|
78, 79, 80, 81, 92, 93, 98, 99, 112, 113, 128, 129, 134, 135, 148, 149, 160, 161, 162, 163, 164,
|
||||||
|
165, 166, 167, 168, 169, 170, 171, 172, 173, 178, 179, 184, 185, 196, 197, 198, 199, 200, 201,
|
||||||
|
202, 203, 204, 205, 206, 207, 208, 209, 214, 215, 220, 221,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Converts non string objects into a string the way Node.js' console.log does it. */
|
||||||
|
function stringify(...data: any[]) {
|
||||||
|
return data
|
||||||
|
.map(obj => {
|
||||||
|
if (typeof obj === 'string') {
|
||||||
|
return obj;
|
||||||
|
} else if (obj instanceof Error) {
|
||||||
|
return formatErrorObj(obj);
|
||||||
|
}
|
||||||
|
return inspect(obj, false, 4, true);
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects a color for a debug namespace.
|
||||||
|
*
|
||||||
|
* Taken from https://github.com/debug-js/debug/blob/master/src/common.js.
|
||||||
|
*/
|
||||||
|
function selectColor(namespace: string) {
|
||||||
|
let hash = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < namespace.length; i++) {
|
||||||
|
hash = (hash << 5) - hash + namespace.charCodeAt(i);
|
||||||
|
hash |= 0; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
|
||||||
|
return debugColors[Math.abs(hash) % debugColors.length]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const formatImplementation = {
|
||||||
|
's': (data: StringLike) => String(data),
|
||||||
|
'd': (data: number) => String(data),
|
||||||
|
'i': (data: number) => String(Math.floor(data)),
|
||||||
|
'f': (data: number) => String(data),
|
||||||
|
'x': (data: number) => data.toString(16),
|
||||||
|
'X': (data: number) => data.toString(16).toUpperCase(),
|
||||||
|
'o': (data: any) => JSON.stringify(data),
|
||||||
|
'O': (data: any) => JSON.stringify(data, null, 2),
|
||||||
|
'c': () => '',
|
||||||
|
'j': (data: any) => JSON.stringify(data),
|
||||||
|
};
|
||||||
|
|
||||||
|
function format(fmt: any, ...args: any[]) {
|
||||||
|
if (typeof fmt === 'string') {
|
||||||
|
let index = 0;
|
||||||
|
const result = fmt.replace(/%[%sdifoxXcj]/g, match => {
|
||||||
|
if (match === '%%') {
|
||||||
|
return '%';
|
||||||
|
}
|
||||||
|
const arg = args[index++];
|
||||||
|
return (formatImplementation as any)[match[1]!](arg);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (index === 0 && args.length > 0) {
|
||||||
|
return result + ' ' + stringify(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return stringify(fmt, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LogFunction = {
|
||||||
|
__proto__: Function.prototype,
|
||||||
|
[Symbol.for('nodejs.util.inspect.custom')](depth: number, options: any) {
|
||||||
|
return options.stylize(`[LogFunction: ${(this as any).name}]`, 'special');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a logger function with a pseudo-random color based off the namespace.
|
||||||
|
*
|
||||||
|
* A custom color can be assigned by doing any of the following:
|
||||||
|
*
|
||||||
|
* - Passing a color argument with a color name "blue"
|
||||||
|
* - Passing a color argument with a hex value "#0000FF"
|
||||||
|
* - Passing a color argument with an ANSI 256 palette value (0-255)
|
||||||
|
* - Passing a color argument with a RGB value [0, 0, 255]
|
||||||
|
* - Using chalk or another formatter on the namespace name.
|
||||||
|
*/
|
||||||
|
export function scoped(
|
||||||
|
name: string,
|
||||||
|
opts: CustomLoggerOptions | CustomLoggerColor = {}
|
||||||
|
): LogFunction {
|
||||||
|
if (typeof opts === 'string' || Array.isArray(opts) || typeof opts === 'number') {
|
||||||
|
opts = { color: opts };
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
id = name,
|
||||||
|
color = undefined,
|
||||||
|
coloredText = false,
|
||||||
|
boldText = false,
|
||||||
|
debug = false,
|
||||||
|
} = opts;
|
||||||
|
const strippedName = stripAnsi(name);
|
||||||
|
const colorFn = name.includes('\x1b')
|
||||||
|
? chalk
|
||||||
|
: color
|
||||||
|
? getColor(color)
|
||||||
|
: chalk.ansi256(selectColor(name));
|
||||||
|
const coloredName = colorFn.bold(name);
|
||||||
|
const fn = ((fmt: unknown, ...args: any[]) => {
|
||||||
|
if (!fn.visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = format(fmt, ...args).replace(/\n/g, '\n ' + ' '.repeat(strippedName.length));
|
||||||
|
|
||||||
|
if (fmt === undefined && args.length === 0) {
|
||||||
|
writeLine();
|
||||||
|
} else {
|
||||||
|
writeLine(
|
||||||
|
coloredName + ' ' + (coloredText ? (boldText ? colorFn.bold(data) : colorFn(data)) : data)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}) as LogFunction;
|
||||||
|
Object.setPrototypeOf(fn, LogFunction);
|
||||||
|
Object.defineProperty(fn, 'name', { value: id });
|
||||||
|
let gen = filterGeneration;
|
||||||
|
let visible = isLogVisible(id, !debug);
|
||||||
|
Object.defineProperty(fn, 'visible', {
|
||||||
|
get: () => {
|
||||||
|
if (gen !== filterGeneration) {
|
||||||
|
gen = filterGeneration;
|
||||||
|
visible = isLogVisible(id, !debug);
|
||||||
|
}
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Built in blue "info" logger. */
|
||||||
|
export const info = /* @__PURE__ */ scoped('info', {
|
||||||
|
color: 'blueBright',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Built in yellow "warn" logger. */
|
||||||
|
export const warn = /* @__PURE__ */ scoped('warn', {
|
||||||
|
color: 'yellowBright',
|
||||||
|
coloredText: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const _trace = /* @__PURE__ */ scoped('trace', {
|
||||||
|
color: 208,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Built in orange "trace" logger. Prints a stack trace after the message. */
|
||||||
|
export const trace = /* @__PURE__ */ ((trace: any) => (Object.defineProperty(trace, 'visible', { get: () => _trace.visible }), trace))(function trace(...data: any[]) {
|
||||||
|
if (_trace.visible) {
|
||||||
|
_trace(...(data.length === 0 ? [' '] : data));
|
||||||
|
writeLine(formatStackTrace(new Error()).split('\n').slice(1).join('\n'));
|
||||||
|
}
|
||||||
|
}) as typeof _trace;
|
||||||
|
|
||||||
|
/** Built in red "error" logger, uses a unicode X instead of the word Error. */
|
||||||
|
export const error = /* @__PURE__ */ scoped(errorSymbol, {
|
||||||
|
id: 'error',
|
||||||
|
color: 'redBright',
|
||||||
|
coloredText: true,
|
||||||
|
boldText: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Built in cyan "debug" logger. */
|
||||||
|
export const debug = scoped('debug', {
|
||||||
|
color: 'cyanBright',
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Built in green "success" logger, uses a unicode Check instead of the word Success. */
|
||||||
|
export const success = /* @__PURE__ */ scoped(successSymbol, {
|
||||||
|
id: 'success',
|
||||||
|
color: 'greenBright',
|
||||||
|
coloredText: true,
|
||||||
|
boldText: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
import chalk, { type ChalkInstance } from 'chalk';
|
||||||
|
import { inspect } from 'util';
|
||||||
|
import { type LogFunction, type CustomLoggerColor, type CustomLoggerOptions, type StringLike, getColor } from './internal.ts';
|
||||||
|
import { formatErrorObj, formatStackTrace } from './error.ts';
|
||||||
|
import stripAnsi from 'strip-ansi';
|
||||||
|
import { writeLine } from './Widget.ts';
|
||||||
|
|
||||||
|
export { writeLine } from './Widget.ts';
|
172
src/error.ts
Normal file
172
src/error.ts
Normal file
|
@ -0,0 +1,172 @@
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import path from 'path';
|
||||||
|
import { isBuiltin } from 'node:module';
|
||||||
|
|
||||||
|
export function platformSimplifyErrorPath(filepath: string) {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
if (filepath.startsWith(cwd)) {
|
||||||
|
return '.' + filepath.slice(cwd.length);
|
||||||
|
}
|
||||||
|
return filepath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A PrintableError is an error that defines some extra fields. `@paperdave/logger` handles these
|
||||||
|
* objects within logs which allows customizing their appearance. It can be useful when building
|
||||||
|
* CLIs to throw formatted error objects that instruct the user what they did wrong, without
|
||||||
|
* printing a huge piece of text with a useless stack trace.
|
||||||
|
*
|
||||||
|
* @see {CLIError} an easy class to construct these objects.
|
||||||
|
*/
|
||||||
|
export interface PrintableError extends Error {
|
||||||
|
description: string;
|
||||||
|
hideStack?: boolean;
|
||||||
|
hideName?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Utility function we use internally for formatting the stack trace of an error. */
|
||||||
|
export function formatStackTrace(err: Error) {
|
||||||
|
if (!err.stack) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const v8firstLine = `${err.name}${err.message ? ': ' + err.message : ''}\n`;
|
||||||
|
const parsed = err.stack.startsWith(v8firstLine)
|
||||||
|
? err.stack
|
||||||
|
.slice(v8firstLine.length)
|
||||||
|
.split('\n')
|
||||||
|
.map(line => {
|
||||||
|
const match = /at (.*) \((.*):(\d+):(\d+)\)/.exec(line);
|
||||||
|
if (!match) {
|
||||||
|
const match2 = /at (.*):(\d+):(\d+)/.exec(line);
|
||||||
|
if (match2) {
|
||||||
|
return {
|
||||||
|
method: '<top level>',
|
||||||
|
file: match2[1],
|
||||||
|
line: match2[2],
|
||||||
|
column: match2[3],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { method: '<unknown>', file: null, line: null, column: null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
method: match[1],
|
||||||
|
file: match[2],
|
||||||
|
line: parseInt(match[3] ?? '0', 10),
|
||||||
|
column: parseInt(match[4] ?? '0', 10),
|
||||||
|
native: line.includes('[native code]'),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: err.stack.split('\n').map(line => {
|
||||||
|
const at = line.indexOf('@');
|
||||||
|
const method = line.slice(0, at);
|
||||||
|
const file = line.slice(at + 1);
|
||||||
|
const fileSplit = /^(.*?):(\d+):(\d+)$/.exec(file);
|
||||||
|
return {
|
||||||
|
method: (['module code'].includes(method) ? '' : method) || '',
|
||||||
|
file: fileSplit ? platformSimplifyErrorPath(fileSplit[1] ?? '') : null,
|
||||||
|
line: fileSplit ? parseInt(fileSplit[2] ?? '0', 10) : null,
|
||||||
|
column: fileSplit ? parseInt(fileSplit[3] ?? '0', 10) : null,
|
||||||
|
native: file === '[native code]',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeModuleJobIndex = parsed.findIndex(
|
||||||
|
line => line.file === 'node:internal/modules/esm/module_job'
|
||||||
|
);
|
||||||
|
if (nodeModuleJobIndex !== -1) {
|
||||||
|
parsed.splice(nodeModuleJobIndex, Infinity);
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.reverse();
|
||||||
|
const sliceAt = parsed.findIndex(line => !line.native);
|
||||||
|
if (sliceAt !== -1) {
|
||||||
|
// remove the first native lines
|
||||||
|
parsed.splice(0, sliceAt);
|
||||||
|
}
|
||||||
|
parsed.reverse();
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
.map(({ method, file, line, column, native }) => {
|
||||||
|
function getColoredDirname(filename: string) {
|
||||||
|
const dirname =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? path.dirname(filename).replace(/^file:\/\/\//g, '')
|
||||||
|
: path.dirname(filename).replace(/^file:\/\//g, '') + path.sep;
|
||||||
|
|
||||||
|
if (dirname === '/' || dirname === './') {
|
||||||
|
return dirname;
|
||||||
|
}
|
||||||
|
return chalk.cyan(dirname);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = native
|
||||||
|
? `[native code]`
|
||||||
|
: file
|
||||||
|
? isBuiltin(file)
|
||||||
|
? `(${chalk.magenta(file)})`
|
||||||
|
: [
|
||||||
|
'(',
|
||||||
|
getColoredDirname(file),
|
||||||
|
// Leave the first slash on linux.
|
||||||
|
chalk.green(path.basename(file)),
|
||||||
|
':',
|
||||||
|
line + ':' + column,
|
||||||
|
')',
|
||||||
|
].join('')
|
||||||
|
: '<unknown>';
|
||||||
|
|
||||||
|
return chalk.blackBright(` at ${method === '' ? '' : `${method} `}${source}`);
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formats the given error as a full log string. */
|
||||||
|
export function formatErrorObj(err: Error | PrintableError) {
|
||||||
|
const { name, message, description, hideStack, hideName, stack } = err as PrintableError;
|
||||||
|
|
||||||
|
return [
|
||||||
|
hideName ? '' : (name ?? 'Error') + ': ',
|
||||||
|
message ?? 'Unknown error',
|
||||||
|
description ? '\n' + description : '',
|
||||||
|
hideStack || !stack ? '' : '\n' + chalk.reset(formatStackTrace(err)),
|
||||||
|
description || (!hideStack && stack) ? '\n' : '',
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When this error is passed to `log.error`, it will be printed with a custom long-description. This
|
||||||
|
* is useful to give users a better description on what the error actually is. Does not show a stack
|
||||||
|
* trace by default.
|
||||||
|
*
|
||||||
|
* For example, in Purplet we throw this error if the `$DISCORD_BOT_TOKEN` environment variable is missing.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* new CLIError(
|
||||||
|
* 'Missing DISCORD_BOT_TOKEN environment variable!',
|
||||||
|
* dedent`
|
||||||
|
* Please create an ${chalk.cyan('.env')} file with the following contents:
|
||||||
|
*
|
||||||
|
* ${chalk.cyanBright('DISCORD_BOT_TOKEN')}=${chalk.grey('<your bot token>')}
|
||||||
|
*
|
||||||
|
* You can create or reset your bot token at ${devPortalLink}
|
||||||
|
* `
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class CLIError extends Error implements PrintableError {
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
constructor(message: string, description: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'CLIError';
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hideStack() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hideName() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
80
src/inject.ts
Normal file
80
src/inject.ts
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { debug, error, info, trace, warn } from './console.ts';
|
||||||
|
import { Spinner } from './Spinner.ts';
|
||||||
|
import { errorAllWidgets, writeLine } from './Widget.ts';
|
||||||
|
|
||||||
|
// Basic Logging Functions
|
||||||
|
console.log = info;
|
||||||
|
console.info = info;
|
||||||
|
console.warn = warn;
|
||||||
|
console.error = error;
|
||||||
|
console.debug = debug;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
console.assert = (condition, ...msg: Parameters<typeof error>) => {
|
||||||
|
if (!condition) {
|
||||||
|
error(...msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Time
|
||||||
|
const timers = new Map<string, { start: number; spinner: Spinner }>();
|
||||||
|
console.time = (label: string) => {
|
||||||
|
if (timers.has(label)) {
|
||||||
|
console.warn(`Timer '${label}' already exists.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timers.set(label, {
|
||||||
|
start: performance.now(),
|
||||||
|
spinner: new Spinner({
|
||||||
|
text: label,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
console.timeEnd = (label: string) => {
|
||||||
|
if (!timers.has(label)) {
|
||||||
|
console.warn(`Timer '${label}' does not exist.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { start, spinner } = timers.get(label)!;
|
||||||
|
timers.delete(label);
|
||||||
|
spinner.success(label + chalk.blackBright(` [${(performance.now() - start).toFixed(3)}ms]`));
|
||||||
|
};
|
||||||
|
console.timeLog = (label: string) => {
|
||||||
|
if (!timers.has(label)) {
|
||||||
|
console.warn(`Timer '${label}' does not exist.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { start } = timers.get(label)!;
|
||||||
|
console.log(label + chalk.blackBright(` [${(performance.now() - start).toFixed(3)}ms]`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const counters = new Map<string, number>();
|
||||||
|
console.count = (label: string) => {
|
||||||
|
const n = (counters.get(label) || 0) + 1;
|
||||||
|
counters.set(label, n);
|
||||||
|
console.log(`${label}: ${n}`);
|
||||||
|
};
|
||||||
|
console.countReset = (label: string) => {
|
||||||
|
counters.set(label, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.trace = trace;
|
||||||
|
|
||||||
|
process.on('uncaughtException', (exception: any) => {
|
||||||
|
errorAllWidgets('uncaught exception');
|
||||||
|
|
||||||
|
error(exception);
|
||||||
|
|
||||||
|
writeLine('The above error was not caught by a catch block, execution cannot continue.');
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
process.on('unhandledRejection', (reason: any) => {
|
||||||
|
errorAllWidgets('unhandled rejection');
|
||||||
|
error(reason);
|
||||||
|
writeLine(
|
||||||
|
'\nThe above error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch()'
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
152
src/internal.ts
Normal file
152
src/internal.ts
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
import chalk, { type ChalkInstance } from 'chalk';
|
||||||
|
import { writeSync } from 'fs';
|
||||||
|
import { inspect } from 'util';
|
||||||
|
|
||||||
|
export function convertHSVtoRGB(h: number, s: number, v: number): [number, number, number] {
|
||||||
|
let r, g, b;
|
||||||
|
const i = Math.floor(h * 6);
|
||||||
|
const f = h * 6 - i;
|
||||||
|
const p = v * (1 - s);
|
||||||
|
const q = v * (1 - f * s);
|
||||||
|
const t = v * (1 - (1 - f) * s);
|
||||||
|
switch (i % 6) {
|
||||||
|
case 0:
|
||||||
|
(r = v), (g = t), (b = p);
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
(r = q), (g = v), (b = p);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
(r = p), (g = v), (b = t);
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
(r = p), (g = q), (b = v);
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
(r = t), (g = p), (b = v);
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
(r = v), (g = p), (b = q);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return [0, 0, 0];
|
||||||
|
}
|
||||||
|
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts non string objects into a string the way Node.js' console.log does it. */
|
||||||
|
export function stringify(...data: any[]) {
|
||||||
|
return data.map(obj => (typeof obj === 'string' ? obj : inspect(obj, false, 4, true))).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer = '';
|
||||||
|
let exiting = false;
|
||||||
|
|
||||||
|
export function writeToStderr(data: string) {
|
||||||
|
buffer += data;
|
||||||
|
if (exiting) flushStderr();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flushStderr() {
|
||||||
|
if (buffer) {
|
||||||
|
writeSync(2, buffer);
|
||||||
|
buffer = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('exit', () => {
|
||||||
|
exiting = true;
|
||||||
|
flushStderr();
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CustomLoggerColor =
|
||||||
|
| 'black'
|
||||||
|
| 'red'
|
||||||
|
| 'green'
|
||||||
|
| 'yellow'
|
||||||
|
| 'blue'
|
||||||
|
| 'magenta'
|
||||||
|
| 'cyan'
|
||||||
|
| 'white'
|
||||||
|
| 'gray'
|
||||||
|
| 'grey'
|
||||||
|
| 'blackBright'
|
||||||
|
| 'redBright'
|
||||||
|
| 'greenBright'
|
||||||
|
| 'yellowBright'
|
||||||
|
| 'blueBright'
|
||||||
|
| 'magentaBright'
|
||||||
|
| 'cyanBright'
|
||||||
|
| 'whiteBright'
|
||||||
|
| `#${string}`
|
||||||
|
| number
|
||||||
|
| [number, number, number];
|
||||||
|
|
||||||
|
export interface CustomLoggerOptions {
|
||||||
|
id?: string;
|
||||||
|
color?: CustomLoggerColor;
|
||||||
|
coloredText?: boolean;
|
||||||
|
boldText?: boolean;
|
||||||
|
level?: number;
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches `string`, `number`, and other objects with a `.toString()` method. */
|
||||||
|
export interface StringLike {
|
||||||
|
toString(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormatStringArgs {
|
||||||
|
'%s': StringLike | null | undefined;
|
||||||
|
'%d': number | null | undefined;
|
||||||
|
'%i': number | null | undefined;
|
||||||
|
'%f': number | null | undefined;
|
||||||
|
'%x': number | null | undefined;
|
||||||
|
'%X': number | null | undefined;
|
||||||
|
'%o': any;
|
||||||
|
'%O': any;
|
||||||
|
'%c': string | null | undefined;
|
||||||
|
'%j': any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProcessFormatString<S> = S extends `${string}%${infer K}${infer B}`
|
||||||
|
? `%${K}` extends keyof FormatStringArgs
|
||||||
|
? [FormatStringArgs[`%${K}`], ...ProcessFormatString<B>]
|
||||||
|
: ProcessFormatString<B>
|
||||||
|
: [];
|
||||||
|
|
||||||
|
export type LogData = string | number | boolean | object | null | undefined;
|
||||||
|
|
||||||
|
export interface LogFunction {
|
||||||
|
/**
|
||||||
|
* Writes data to the log. The first argument can be a printf-style format string, or usage
|
||||||
|
* similar to `console.log`. Handles formatting objects including `Error` objects with pretty
|
||||||
|
* colorized stack traces.
|
||||||
|
*
|
||||||
|
* List of formatters:
|
||||||
|
*
|
||||||
|
* - %s - String.
|
||||||
|
* - %d, %f - Number.
|
||||||
|
* - %i - Integer.
|
||||||
|
* - %x - Hex.
|
||||||
|
* - %X - Hex (uppercase)
|
||||||
|
* - %o - Object.
|
||||||
|
* - %O - Object (pretty printed).
|
||||||
|
* - %j - JSON.
|
||||||
|
*/
|
||||||
|
<S extends LogData>(data?: S, ...a: ProcessFormatString<S>): void;
|
||||||
|
/** Calling a logger function with no arguments prints a blank line. */
|
||||||
|
(): void;
|
||||||
|
|
||||||
|
visible: boolean;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColor(color: CustomLoggerColor): ChalkInstance {
|
||||||
|
if (typeof color === 'string') {
|
||||||
|
return color in chalk ? (chalk as any)[color] : chalk.hex(color);
|
||||||
|
} else if (Array.isArray(color)) {
|
||||||
|
return chalk.rgb(color[0], color[1], color[2]);
|
||||||
|
}
|
||||||
|
return chalk.ansi256(color);
|
||||||
|
}
|
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue