commit 89c29a54ee9518de0a9d6b0bac4bcb2550c4d648 Author: chloe caruso Date: Fri Mar 14 20:24:43 2025 -0700 initial revival diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5e15882 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ef469e --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..7a8f617 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/examples/basic.ts b/examples/basic.ts new file mode 100644 index 0000000..e0501b0 --- /dev/null +++ b/examples/basic.ts @@ -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!'); \ No newline at end of file diff --git a/examples/inject.ts b/examples/inject.ts new file mode 100644 index 0000000..65111ab --- /dev/null +++ b/examples/inject.ts @@ -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'); diff --git a/examples/progress-bar.ts b/examples/progress-bar.ts new file mode 100644 index 0000000..85dbd6d --- /dev/null +++ b/examples/progress-bar.ts @@ -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; + } +}); diff --git a/examples/spinner.ts b/examples/spinner.ts new file mode 100644 index 0000000..93a6c93 --- /dev/null +++ b/examples/spinner.ts @@ -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); +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..dd96433 --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/src/Progress.ts b/src/Progress.ts new file mode 100644 index 0000000..00b6cd5 --- /dev/null +++ b/src/Progress.ts @@ -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 = Record> { + /** Text displayed to the right of the bar. */ + text: string | ((props: ExtendedProps) => string); + /** Text displayed to the left of the bar, if specified. */ + beforeText?: string | ((props: ExtendedProps) => 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 | 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 & { + value: number; + total: number; + /** Number 0-1, inclusive. */ + progress: number; +}; + +export class Progress = Record> extends Widget { + #text: string | ((props: ExtendedProps) => string); + #beforeText: string | ((props: ExtendedProps) => string); + #barWidth: number; + #barStyle: NonNullable; + #spinnerColor: NonNullable; + #spinnerFrames?: readonly string[]; + #props: Props; + #spinnerFPS: number; + #value: number; + #total: number; + fps: number; + + constructor(options: ProgressOptions | 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) { + this.#props = { + ...this.#props, + ...value, + }; + this.redraw(); + } + + get props(): ExtendedProps { + 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) { + 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, T> + extends ProgressOptions { + /** 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, T>( + opts: WithProgressOptions | string, + fn: (bar: Progress) => Promise +): Promise; +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; + } +} diff --git a/src/Spinner.ts b/src/Spinner.ts new file mode 100644 index 0000000..b0ae2c4 --- /dev/null +++ b/src/Spinner.ts @@ -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> { + /** 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 = Record> extends Widget { + #text: string | ((props: Props) => string); + #color: ((text: string) => string) | null; + #frames: readonly string[]; + #props: Props; + fps: number; + + constructor(options: SpinnerOptions | 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) { + 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): void; + update(newMessage: string): void; + update(newData: string | Partial) { + 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, T> + extends SpinnerOptions { + successText?: string | ((result: T) => string); + failureText?: string | ((error: Error) => string); +} + +/** Calls a function with a spinner. */ +export async function withSpinner, T>( + spinnerOptions: WithSpinnerOptions | string, + fn: (spinner: Spinner) => Promise +): Promise; +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; + } +} diff --git a/src/Widget.ts b/src/Widget.ts new file mode 100644 index 0000000..07839a5 --- /dev/null +++ b/src/Widget.ts @@ -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(); +} diff --git a/src/console.ts b/src/console.ts new file mode 100644 index 0000000..a94bc96 --- /dev/null +++ b/src/console.ts @@ -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) { + 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'; diff --git a/src/error.ts b/src/error.ts new file mode 100644 index 0000000..340e034 --- /dev/null +++ b/src/error.ts @@ -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: '', + file: match2[1], + line: match2[2], + column: match2[3], + }; + } + return { method: '', 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('') + : ''; + + 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('')} + * + * 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; + } +} diff --git a/src/inject.ts b/src/inject.ts new file mode 100644 index 0000000..601060d --- /dev/null +++ b/src/inject.ts @@ -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) => { + if (!condition) { + error(...msg); + } +}; + +// Time +const timers = new Map(); +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(); +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); +}); \ No newline at end of file diff --git a/src/internal.ts b/src/internal.ts new file mode 100644 index 0000000..723719a --- /dev/null +++ b/src/internal.ts @@ -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 extends `${string}%${infer K}${infer B}` + ? `%${K}` extends keyof FormatStringArgs + ? [FormatStringArgs[`%${K}`], ...ProcessFormatString] + : ProcessFormatString + : []; + +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. + */ + (data?: S, ...a: ProcessFormatString): 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); +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..56d9af3 --- /dev/null +++ b/tsconfig.json @@ -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 + } +}