initial revival

This commit is contained in:
chloe caruso 2025-03-14 20:24:43 -07:00
commit 89c29a54ee
17 changed files with 1422 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules

18
LICENSE Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}
}