topkat-utils
Version:
A comprehensive collection of TypeScript/JavaScript utility functions for common programming tasks. Includes validation, object manipulation, date handling, string formatting, and more. Zero dependencies, fully typed, and optimized for performance.
359 lines (334 loc) • 15.1 kB
text/typescript
/* eslint-disable no-console */
/* eslint-disable no-control-regex */
//----------------------------------------
// LOGGER
//----------------------------------------
import { configFn } from './config'
import { isset } from './isset'
import { Color } from './types'
import { removeCircularJSONstringify } from './remove-circular-json-stringify'
import { cleanStackTrace } from './clean-stack-trace'
type NotInfoLogLevel = 'error' | 'warn'
type LogLevels = NotInfoLogLevel | 'info'
export const logger = {
/** log is an OUTDATED alisas of info, appError is for warning */
log(str: string, level: LogLevels = 'info') {
const { preprocessLog } = configFn()
if (typeof preprocessLog === 'function') str = preprocessLog(str) || str
if (isset(console[level])) console[level](str)
else console.log(str)
logger.raw.push(str + `\n`)
logger.raw = logger.raw.slice(logger.raw.length - configFn()?.nbOfLogsToKeep, logger?.raw?.length)
},
/**
* @param {String[]|String} inputLogs
*/
toHtml(inputLogs = [...logger.raw]) {
if (!Array.isArray(inputLogs)) inputLogs = [inputLogs]
const code2css = {
2: `opacity:.5`, // dim
32: `color:#679933`, // green
31: `color:#A8383B`, // red
33: `color:#D7941C`, // yellow
35: `color:#C21949`, // magenta
36: `color:#128C6D`, // cyan
34: `color:#1B568B`, // blue
}
let htmlLogs = ''
inputLogs.join('<br/>').split('\x1b[').forEach((bit, i) => {
// the first doesn't have a preceding (may be undef)
if (bit) {
if (i !== 0) {
const [, code, R, G, B, content] = bit.match(/(^\d\d?)(?:;|m)(?:\d;(\d+);(\d+);(\d+)m)?(.*$)/) || []
if (content) {
const style = !isset(R) ? isset(code) ? code2css[code] : undefined : `color:rgb(${R},${G},${B})`
htmlLogs += isset(style) ? `<span style='${style}'>${content.replace(/\n/g, '<br>')}</span>` : content.replace(/\n/g, '<br>')
}
} else htmlLogs += bit.replace(/\n/g, '<br>')
}
})
return `<div style='color:#ccc'>${htmlLogs}<br></div>`
},
toText(inputLogs = [...logger.raw]) {
const str = Array.isArray(inputLogs) ? inputLogs.join('\n') : inputLogs
return str.replace(/\x1b\[.*?m/g, '')
},
raw: [] as string[],
json: [] as string[],
}
/**
// console colored output
// * console.log(C.green(C.dim('Hey bro !')))
// * or C.log() // will use color defined by themes
// * or C.line('MY TITLE', 53)
// * or C.gradientize(myLongString)
*/
export const C = {
dim: str => C.output(2, str), // opacity 0.5
green: str => C.output(32, str),
red: str => C.output(31, str),
yellow: str => C.output(33, str),
grey: str => C.output(2, str),
magenta: str => C.output(35, str),
cyan: str => C.output(36, str),
blue: str => C.output(34, str),
primary: str => {
const primary: Color = configFn()?.terminal?.theme?.primary
return C.rgb(...primary) + str + C.reset
},
reset: '\x1b[0m',
output: (code, str = '') => configFn()?.terminal?.noColor ? str : `\x1b[${code}m${str}\x1b[0m`,
// true RGB colors B-*
rgb: (r, g = 0, b = 0) => configFn()?.terminal?.noColor || !isset(r, g, b) ? '' : `\x1b[38;2;${r};${g};${b}m`,
bg: (r?, g?, b?) => configFn()?.terminal?.noColor || !isset(r, g, b) ? '' : `${'\x1b['}48;2;${r};${g};${b}m`,
/** Output a line of title */
line(title = '', length = configFn()?.terminal?.theme?.pageWidth, clr = configFn()?.terminal?.theme?.primary, char = '=') {
this.log('\u00A0\n' + this.rgb(...clr) + (title + ' ').padEnd(length || 0, char) + this.reset + '\u00A0\n')
},
/** Eg: ['cell1', 'cell2', 'cell3'], [25, 15] will start cell2 at 25 and cell 3 at 25 + 15
* @param {Array} limits default divide the viewport
*/
cols(strings, limits: number[] = [], clr = configFn()?.terminal?.theme?.fontColor) {
if (!limits.length) {
const colWidth = Math.round(configFn()?.terminal?.theme.pageWidth / strings.length)
limits = Array(strings.length - 1).fill(2).map((_, i) => colWidth * i + 1) as number[]
}
const str = strings.reduce((glob, str = '', i) => {
const realCharLength = str.toString().replace(/\x1b\[.*?m/, '').length
const charLength = str.toString().length
const realLimit = limits[i] + charLength - realCharLength
return glob + str.toString().substring(0, realLimit || 999).padEnd(realLimit || 0, ' ')
}, '')
this.logClr(str, clr)
},
/** Console log alias */
log(...stringsCtxMayBeFirstParam) {
stringsCtxMayBeFirstParam.forEach(str => this.logClr(str))
},
logClr(str, clr = configFn()?.terminal?.theme?.fontColor) {
if (!isset(str)) return
str = (typeof clr !== 'undefined' ? this.rgb(...clr) : '') + str.toString().replace(/\n/g, '\n' + (typeof clr !== 'undefined' ? this.rgb(...clr) : ''))
logger.log(str + this.reset, 'info')
},
info(...str) {
str.forEach((s, i) => {
if (i === 0) this.logClr('ⓘ ' + s, configFn()?.terminal?.theme?.primary)
else this.log(this.dimStrSplit(s))
})
this.log(' ')
},
success(...str) {
str.forEach((s, i) => {
if (i === 0) this.log(this.green('✓ ' + s))
else this.log(this.dimStrSplit(s))
})
},
/** First param **false** to avoid logging stack trace */
error: (...errors) => logErrPrivate('error', [255, 0, 0], ...errors),
/** First param **false** to avoid logging stack trace */
warning: (...str) => logErrPrivate('warn', [255, 122, 0], ...str),
customError: (color, ...str) => logErrPrivate('error', color, ...str),
customWarning: (color, ...str) => logErrPrivate('warn', color, ...str),
applicationError: (color, ...str) => logErrPrivate('warn', color, ...str),
warningLight: (_, ...str) => logErrPrivate('warn', [196, 120, 52], ...str),
dimStrSplit(...logs) {
const logsStr: string[] = []
logs.filter(isset).forEach(log => log.toString().split('\n').forEach(line => line && logsStr.push(this.dim(` ${line}`))))
return logsStr.join('\n')
},
notifShow() {
console.log('\n\u00A0')
this.notifications.forEach(fn => fn())
this.notifications = []
console.log('\n\u00A0')
},
/** Keep in memory the logs to show when needed with C.notifShow()
* Ex: C.notification('info', str); */
notification(type, ...messages) {
this.notifications.push(() => {
if (isset(this[type])) {
this[type](...messages)
} else {
this.warning('Wrong param for C.notification')
}
})
},
notifications: [] as any[], // fn array
/** Gratientize lines of text (separated by \n) */
gradientize(str = '', rgb1 = configFn()?.terminal?.theme?.shade1, rgb2 = configFn()?.terminal?.theme?.shade2, bgRgb = configFn()?.terminal?.theme?.bgColor, paddingY = 2) {
const lines = str.split('\n')
const largestLine = lines.reduce((sum, line) => sum < line.length ? line.length : sum, 0)
const rgbParts = rgb1.map((val, i) => (val - rgb2[i]) / (lines.length))
const bg = bgRgb ? this.bg(bgRgb[0], bgRgb[1], bgRgb[2]) : ''
const padLine = bg + ' '.padEnd(largestLine, ' ') + '\x1b[0m\n'
console.log(padLine.repeat(paddingY) +
lines.reduce((s, line, i) => {
return s + bg + this.rgb(...((rgb1 as Color).map((val, i2) => Math.round(val - i * rgbParts[i2]))) as Color) + line.padEnd(largestLine, ' ') + '\x1b[0m\n'
}, '') +
padLine.repeat(paddingY))
},
debugModeLog(title, ...string) {
this.logClr('🐞 ' + title, configFn()?.terminal?.theme?.debugModeColor)
this.log(this.dimStrSplit(...string))
},
/** allow to clear the last lines written to console */
clearLastLines(n: number = 2) {
for (let i = 0; i < n; i++) {
process?.stdout?.moveCursor?.(0, -1) // Move up one line
process?.stdout?.clearLine?.(0)
}
process?.stdout?.cursorTo?.(0) // Move cursor to beginning of the current line
}
}
function logErrPrivate(level: NotInfoLogLevel, color: Color, ...errors) {
const { isProd } = configFn()
if (errors.length === 1 && typeof errors[0]?.log === 'function') return errors[0].log()
let stackTrace = (new Error('')).stack || ''
const displayStack = errors[0] === false ? errors.shift() : true
const symbol = level === 'error' ? '✘ ' : '⚠ '
if (errors.length > 1 && !isset(errors[0])) errors.shift() // remove first empty object
const getStringFromErr = (err, i) => {
if (!isset(err)) return ''
else if (typeof err === 'string') {
if (i === 0) return C.rgb(...color) + symbol + err + C.reset
else return err.split('\n').map(val => C.dim(val)).join('\n')
} else if (err instanceof Error) {
const { str, stackTrace: stkTrc } = stringifyInstanceOfError(err, level, color)
if (stkTrc) stackTrace = stkTrc
return str
} else if (typeof err === 'object') {
let msg = ''
msg += removeCircularJSONstringify(err, 2).split('\n').map(val => C.dim(val)).join('\n') + '\n'
const { str, stackTrace: stkTrc } = stringifyExtraInfos(err.extraInfo || err, level, color)
if (stkTrc) stackTrace = stkTrc
msg += str
return msg
} else return ''
}
if (errors.length && errors[0]) {
const messages = errors.map((e, i) => {
if (typeof e?.log === 'function') {
e.log()
return ''
} else return getStringFromErr(e, i)
})
// Stack
if (displayStack) {
messages.push(isProd ? stackTrace : cleanStackTrace(stackTrace) + '\n')
}
logger.log(messages.join(''), level)
}
}
function stringifyInstanceOfError(err, type = 'error', color: Color = [255, 0, 0], level = 0) { // level = keep track of recursions
if (level > 5) return { str: '' }
let str = ''
let stackTrace
const symbol = type === 'error' ? '✘ ' : '⚠ '
const title = err.msg || err.message || err.id || (err.stack ? err.stack.split('\n')[0] : 'Error')
// Err mess
str += C.rgb(...color) + symbol + title + C.reset + '\n'
if (err.stack) stackTrace = err.stack // more relevant
// ExtraInfos
if (isset(err.extraInfo)) {
const { str: str2, stackTrace: stkTrc } = stringifyExtraInfos(err.extraInfo, type, color, level++)
if (stkTrc) stackTrace = stkTrc
str += str2
}
return { str, stackTrace }
}
function stringifyExtraInfos(extraInfoOriginal, type, color, level = 0) {
let stackTrace
const originalError = [C.dim(`ORIGINAL ERROR ${'-'.repeat(39)}\n`)]
if (extraInfoOriginal instanceof Error) { // case where error is passed directly to extraInfos
return stringifyInstanceOfError(extraInfoOriginal, type, color)
} else {
const extraInfo = { ...extraInfoOriginal }
const extraInfos = [C.dim(`EXTRA INFOS ${'-'.repeat(41)}\n`)]
if (typeof extraInfo === 'object' && Object.keys(extraInfo).length) {
for (const itemName in extraInfo) {
if (extraInfo[itemName] instanceof Error) {
const { str, stackTrace: stkTrc } = stringifyInstanceOfError(extraInfo[itemName], type, color, level++)
originalError.push(str)
stackTrace = stkTrc
delete extraInfo[itemName]
}
}
}
if (Object.keys(extraInfo).length) {
extraInfos.push(
removeCircularJSONstringify(extraInfo, 2)
.replace(/(?:^\s*{(?:\n {2})?|}\s*$)/g, '')
.replace(/\n {2}/g, '\n')
.split('\n')
.map(val => C.dim(val)).join('\n') + '\n')
}
return {
str: (extraInfos.length > 1 ? extraInfos.join('') : '') + (originalError.length > 1 ? originalError.join('') + '\n' : ''),
stackTrace
}
}
}
/**
* Call this at each steps of your progress and change the step value
* @param {Number} step Number of "char" to output
* @param {String} char Default: '.'
* @param {String} msg String before char. Final output will be `${str}${char.repeat(step)}`
*/
export function cliProgressBar(step: number, char = '.', msg = `\x1b[2mⓘ Waiting response`) {
if (typeof process?.stdout?.clearLine === 'function') {
process.stdout.cursorTo(0)
process.stdout.write(`${msg}${char.repeat(step)}\x1b[0m`) // \x1b[0m == reset color
}
}
type loadingSpinnerTypes = 'arrow' | 'dots'
/** This allow an intuitive inline loading spinner with a check mark when loading as finished or a red cross for errors */
export class cliLoadingSpinner {
/** Please use it like spinner.start('myStuff') then spinner.end()
* @param {String} type in: ['arrow', 'dots']
*/
frameRate: number
animFrames: string[]
activeProcess: any
frameNb: number = 0
progressMessage: string = ''
interval: any = -1
constructor(type = 'dots' as loadingSpinnerTypes, activeProcess = process) {
const anims = {
arrow: {
interval: 120,
frames: ['▹▹▹▹▹', '▸▹▹▹▹', '▹▸▹▹▹', '▹▹▸▹▹', '▹▹▹▸▹', '▹▹▹▹▸']
},
dots: {
interval: 80,
frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
}
}
this.frameRate = anims[type].interval
this.animFrames = anims[type].frames
this.activeProcess = activeProcess
}
start(msg) {
this.frameNb = 0
this.progressMessage = msg
this.interval = setInterval(() => {
this.activeProcess?.stdout?.cursorTo?.(0) // Move cursor to beginning of the current line
const symbol = this.animFrames[this.frameNb++ % this.animFrames.length]
this.activeProcess.stdout.write(C.primary(symbol) + ' ' + this.progressMessage)
}, this.frameRate)
}
end(error = false) {
clearInterval(this.interval)
this.activeProcess?.stdout?.cursorTo?.(0)
C.log(
error ? C.red('✘ ' + this.progressMessage + '\n\n')
: '\x1b[32m✓ ' + this.progressMessage + '\n\n'
)
this.progressMessage = ''
}
error() {
return this.end(true)
}
}
export function dim(str = '') {
return configFn()?.terminal?.noColor ? str : `\x1b[2m${str.toString().split('\n').join('\x1b[0m\n\x1b[2m')}\x1b[0m`
}