UNPKG

@syncify/ansi

Version:

ANSI Colors, Symbols and TUI related terminal enchancements for Syncify.

839 lines (692 loc) 17.4 kB
import type { Ansis } from 'ansis'; import type { LiteralUnion } from 'type-fest'; import wrap from 'wrap-ansi'; import { glue } from '@syncify/glue'; import { WSP } from './characters'; import { bold, gray, lightGray, neonRouge, red, redBright, reset, strip, underline, whiteBright, yellow, yellowBright } from './colors'; import { detect, eq, getTime, sanitize } from './helpers'; import { cleanStack } from './stack'; import { ARR, BAD, COL, LAN, LCB, LPR, LSB, NXT, RAN, RCB, RPR, RSB, TLD } from './symbols'; import { Tree } from './tree'; import { tsize } from './tsize'; import { Create } from './tui'; /** * Log Prefixes * * The CLI logs will be prefixed with the different naming groups. * Each prefix name infers an action pertaining to an executed operation. * Depending on the prefix name character length of the arrow separator * will equally distributed. */ export type Prefixes = LiteralUnion<( | 'changed' | 'create' | 'created' | 'creating' | 'delete' | 'deleted' | 'deleting' | 'deletion' | 'elapsed' | 'export' | 'external' | 'failed' | 'failures' | 'ignored' | 'importer' | 'invalid' | 'minified' | 'pending' | 'process' | 'processing' | 'transferred' // Longest Prefix with length of 11 | 'progress' | 'publish' | 'queued' | 'rejected' | 'reloaded' | 'release' | 'retrying' | 'skipped' | 'syncing' | 'transform' | 'template' | 'updated' | 'updating' | 'uploaded' | 'version' | 'warning' ), string> /** * Prefix limit reference to equally distribute prefix spacing */ const PREFIX_LIMIT = 9; /** * The additional spacing to apply for equal distribution */ const PREFIX_EXTRA = ' '; /** * The number of whitespace characters to subtract from. */ const PREFIX_SPACE = PREFIX_LIMIT + PREFIX_EXTRA.length; /** * Regular Expression for timer suffix */ const TIME_SUFFIX = /\d+[μmsec]{1,3}$/; /** * ANSI Prefix * * Equally distributes whitespace following the `prefix` parameter. * Optionally accepts a `suffix[]` string spread. Depending on the number * of suffix appends passed, different output is produced. When passing * `3 or 4` suffixes the last known suffix will apply `~` appenditure. * * See below examples: * * --- * * **Passing 0 `suffix` parameters** * * ```bash * │ prefix * ``` * * --- * * **Passing 1 `suffix` parameter** * * ```bash * │ prefix » action * ``` * --- * * **Passing 2 `suffix` parameters (applies append is timer)** * * ```bash * │ prefix » action → suffix * │ prefix » action ~ append * ``` * * --- * * **Passing 3 `suffix` parameters** * * ```bash * │ prefix » action → suffix ~ append * ``` * * --- * * **Passing 4 `suffix` parameters** * * ```bash * │ prefix » handle ⥂ joiner → action ~ append * ``` */ export function Prefix (name: Prefixes, ...suffix: [ handle?: string, joiner?: string, action?: string, append?: string ]) { const label = detect(name) ? strip(name).trim() : name.trim(); const spacer = label.length > PREFIX_LIMIT ? PREFIX_EXTRA : ' '.repeat(PREFIX_SPACE - label.length); const ICO = /^(error|invalid|failed|rejected)$/.test(label) ? BAD : NXT; const prefix: string = label + PREFIX_EXTRA + spacer + ICO + PREFIX_EXTRA; const length = suffix.length; if (length > 0) { if (length === 1) { // name → handle return glue.ws(prefix, suffix[0]); } else if (length === 2) { // name → handle → joiner // name → handle ~ append return TIME_SUFFIX.test(strip(suffix[1])) ? glue.ws(prefix, suffix[0], Append(suffix[1])) : glue.ws(prefix, suffix[0], ARR, suffix[1]); } else if (length === 3) { // name → handle → joiner ~ append return glue.ws(prefix, suffix[0], ARR, suffix[1], Append(suffix[2])); } else if (length === 4) { // name → handle → joiner → action ~ append return glue.ws(prefix, suffix[0], ARR, suffix[1], ARR, suffix[2], Append(suffix[3])); } } // prefix return prefix; } /** * Suffix in gray with Tilde `~` prefix * * **Examples** * * ```bash * ~ 250ms * ~ Lorem Ipsum * ``` */ export function Append (input: string) { return input ? TLD + ' ' + reset.gray(input) : ''; } /** * ANSI Infix * * Infixes input with certain characters. Optionally accepts a * `spaced` value, when provided, the _encase_ will be surrounded * with a single whitespace character. * * **Encase Shortcodes** * * - **AN** ~ `<input>` * - **CB** ~ `{input}` * - **PR** ~ `(input)` * - **SB** ~ `[input]` * * --- * * **Spaced Settings** * * Whether or not to encase with single whitespace characters. * When `true` the `input` and encase character are expressed as: * * - `< input >` * - `{ input }` * - `( input )` * - `[ input ]` * */ export function Encase (encase: 'AN'| 'CB'| 'PR'| 'SB', input: string, { spaced = false } = {}) { const WS = spaced ? ' ' : ''; switch (encase) { case 'AN': return LAN + WS + input + WS + RAN; case 'CB': return LCB + WS + input + WS + RCB; case 'PR': return LPR + WS + input + WS + RPR; case 'SB': return LSB + WS + input + WS + RSB; } } /* -------------------------------------------- */ /* SUFFIXES */ /* -------------------------------------------- */ const Suffix: { /** * Warning in yellow stdin suffix with Tilde `~` prefix * * ```bash * ~ Type w and press enter to view * ``` */ warning: string; /** * Error in red stdin suffix with Tilde `~` prefix * * ```bash * ~ Type v and press enter to view * ``` */ error: string; /** * Bulk log inspection suffix * * ```bash * ~ Type v and press enter to view * ``` */ bulk: string; /** * Stack Trace in Gray applied to error contexts * * ```bash * Type s and press enter to view stack trace * ``` */ stack: string; } = Object.create(null); Suffix.warning = yellow(` ${TLD} Type ${bold('w')} and press ${bold('enter')} to view all warning/s`); Suffix.error = red(` ${TLD} Type ${bold('v')} and press ${bold('enter')} to view all error/s`); Suffix.stack = gray(`Type ${bold('s')} and press ${bold('enter')} to view stack trace`); Suffix.bulk = gray(`Type ${bold('i')} and press ${bold('enter')} to inspect bulk file/s`); export { Suffix }; /* -------------------------------------------- */ /* TREE ANSI */ /* -------------------------------------------- */ /** * TUI Horizontal Line * * Prints a horizontal line separator which will default to * spanning the `wrap` of the terminal pane. * * ```bash * │\n * ├──────────────────────────────────────────────── * │ * ``` */ export const Ruler = (width = undefined, newlines = true) => { if (width === undefined) width = tsize().wrap; const line = lightGray.open + '├' + '─'.repeat(width - 10) + lightGray.close; if (newlines) return Tree.trim + '\n' + line + '\n' + Tree.trim; return line; }; /** * Tree Top * * ``` * '\n┌─ Label ~ 01:59:20' * ``` */ export function Top (label: string, timestamp = true) { return Tree.open + reset.gray(timestamp ? `${label} ~ ${getTime()}` : label); } type MultilineParams = | string[] | [string[], style?: { color?: Ansis; line?: string; }] | [...string[], { color?: Ansis; line?: string; }]; /** * Tree Multiline * * Prefixes a multiline string with tree line but does not respect wrap. * If a string with newlines is passed, the newline occurance will be * respected. * * > **NOTE** * > * > Any extraneous newline applied will be sliced in the result. This * > means that you need to pass `\n` to the multiline to ensure ending * > newline applied, e.g, `Multiline('abc\n')`. If multiline string does * > not include an ending newline one will not be applied. * * ```bash * │ lorem ipsum lorem ipsum\n * │ lorem ipsum lorem ipsum\n * │ lorem ipsum lorem ipsum * ``` */ export const Multiline = (...input: MultilineParams) => { const style: { color?: Ansis; line?: string; } = { color: null, line: Tree.line }; let write: string = ''; let lines: string[]; if (Array.isArray(input[0])) { if (typeof input[1] === 'object') { Object.assign(style, input[1]); } lines = input[0]; } else { if (typeof input[input.length - 1] === 'object') Object.assign(style, input.pop()); lines = input as string[]; } while (lines.length !== 0) { let line = lines.shift(); if (/^\n+$/.test(line)) { const nl = line.split('\n').length - 1; for (let i = 0; i < nl; i++) write += style.line + '\n'; } else { line = line.trim(); if (line.length > 0) { write += style.line + (style.color ? style.color(line) : line) + '\n'; } else { write += style.line + '\n'; } } } return write.slice(0, -1); }; interface WrapOptions { /** * The color of the message * * @default null */ color?: Ansis; /** * The `Tree.line` to apply * * @default Tree.line */ line?: string; /** * Whether or not the first line is to begin like Tree line. * * Default behaviour: `{ firstLineTree: true }` * * ```js * '│ lorem ipsum lorem ipsum' // this line begins with │ * '│ lorem ipsum lorem ipsum' * '│ lorem ipsum lorem ipsum' * ``` * * When disabled: `{ firstLineTree: false }` * * ```js * 'lorem ipsum lorem ipsum' // this line does not apply prefix * '│ lorem ipsum lorem ipsum' * '│ lorem ipsum lorem ipsum' * ``` * * @default true */ firstLineTree?: boolean; } /** * Tree Wrap * * Accepts `string[]` or `...string[]` spread. The last entry accepts an * optional **style** config, which can be used to pass in **Ansis** color * and/or tree line type (e.g: `Tree.red` or `Tree.yellow`). The `line` key * will default to using `Tree.line` (i.e: _lightGray_) and `color` defaults * to `null` and will apply according to what was passed. * * * ``` * │ lorem ipsum lorem ipsum * │ lorem ipsum lorem ipsum * │ lorem ipsum lorem ipsum * ``` */ export const Wrap = <T extends WrapOptions> (...input: [ string[], T?] | (string | T)[]) => { const style: T = <T>{ color: null, line: Tree.line, firstLineTree: true }; const width: number = tsize().wrap - 5; let lines: string[]; let write: string = ''; if (Array.isArray(input[0])) { // Update options // if (typeof input[1] === 'object') Object.assign(style, input[1]); lines = wrap(glue.ws(input[0]), width, { hard: true }).split('\n'); } else { // Update options // if (typeof input[input.length - 1] === 'object') Object.assign(style, input.pop()); lines = wrap(input.join(' '), width, { hard: true }).split('\n'); } for (let i = 0, s = lines.length; i < s; i++) { const line = lines[i]; const tree = i === 0 && style.firstLineTree === false ? '' : style.line; write += ( tree + ( line.length > 0 ? (style.color ? style.color(line) : line) : '' ) + '\n' ); } return write.trimEnd(); }; /** * Tree Line Break * * ``` * │ * │ input * │ * ``` */ export const Header = (input: string) => Tree.trim + '\n' + Tree.line + input + '\n' + Tree.trim; /** * Tree Line Break Red * * ``` * │ * │ input * │ * ``` */ export const HeaderRed = (input: string) => Tree.redTrim + '\n' + Tree.red + red(input) + '\n' + Tree.redTrim; /** * Tree Line Break * * ```bash * │ * │ input * │ * ``` */ export function HeaderYellow (input: string) { return Tree.yellowTrim + '\n' + Tree.yellow + yellow(input) + '\n' + Tree.yellowTrim; } /** * Tree Line * * ```bash * │ input * ``` */ export function Line (input: string) { return Tree.line + input; } /** * Tree Red Line * * ```bash * │ input * ``` */ export function LineRed (input: string) { return Tree.red + input; } /** * Tree Warn Line * * ```bash * │ input * ``` */ export function LineYellow (input: string) { return Tree.yellow + input; } /** * Tree Next Line * * ```bash * │\n * │ input * ``` */ export function NextLine (input: string) { return Tree.trim + '\n' + Tree.line + input; } /** * Tree Line Next * * ```bash * │ input\n * │ * ``` */ export function Next (input: string) { return Tree.line + input + '\n' + Tree.line; } /** * Tree Dash * * ```bash * ├─ input * ``` */ export function Dash (input: string) { return Tree.dash + input; } /** * Tree End * * ```bash * └─ input\n * ``` */ export function End (input: string, timestamp = true) { return Tree.base + reset.gray(timestamp ? `${input} ~ ${getTime()}` : input) + '\n'; } /** * Tree Indent Line * * ```bash * │ │ input * ``` */ export function IndentLine (input: string) { return Tree.indent.line + input; } /** * Tree Indent Line Dash * * ```bash * │ ├─ input * ``` */ export function IndentDash (input: string) { return Tree.indent.dash + input; } /** Tree Indent Line Dash * * ```bash * │ └─ input\n * │ * ``` */ export function IndentEnd (input: string) { return Tree.indent.base + input + '\n' + Tree.trim; } /** * Tree Count up rendered * * ```bash * │ └─ input\n * │ * ``` */ export function CountUp (count = 0, total = 0, color: Ansis = whiteBright) { return Tree.line + color(bold(count) + ' of ' + bold(total)); } export interface IssueContext { /** * The type of context to generate * * @default 'error' */ type?: 'error' | 'warning' /** * Whether or not tree line prefix is to apply * * @default true */ tree?: boolean /** * The stack trace messages, typically provided by the error. * * When `stack` message are passed, they will be prepended above * the `entries`. * * When `true` then stack will be stored in running `$` state * and made available via stdin input, this will result in a * stack suffix being appended to the context, * * If this value is `false` (or `undefined`) then no stack * handling is done. * * @default false */ stack?: boolean | string; /** * Whether or not the stack should be cleaned using `cleanStack` * module. This helps bring sanity to errors, not always a good choice. * * > **NOTE** * > * > If `stack` is `false` this option is ignored, * * @default false */ cleanStack?: boolean; /** * Context entries - This a collection of `key` > `value` * pairs to be appended. */ entries: { [name: string]: any } } /** * Error and/or Warning context normalizer. * * There is special handling for entries with a `failed` key, * this accepts an array of strings. * * **Example** * * ``` * │ * │ code: 422 * │ status: Unprocessed Entity * │ failed: ~source/sections/file.liquid * │ failed: ~source/sections/file.liquid * │ * │ Type s and press enter to view stack trace * ``` */ export function Context (data: IssueContext) { const space = eq(data.entries); const tui = Create({ type: data.type || 'error', tree: 'tree' in data ? data.tree : true }).Newline(); if (typeof data.stack === 'string') { let stack = data.cleanStack ? cleanStack(data.stack, { pretty: true }) : data.stack; if (/TypeError/.test(stack.trimStart())) { stack = stack.slice(stack.indexOf('\n') + 1).replace(/^ +/gm, ARR + WSP); } tui.Multiline(gray(stack)).Newline(); } let line: string = ''; let col: string = ''; if ('line' in data.entries) { line = `:${typeof data.entries.line === 'number' ? data.entries.line : strip(data.entries.line)}`; } if (line !== '' && 'column' in data.entries) { col = `:${typeof data.entries.column === 'number' ? data.entries.column : strip(data.entries.column)}`; } // generate output for (const key in data.entries) { if (data.entries[key] === undefined) continue; let string: string; const isFailed = key === 'failed'; if (typeof data.entries[key] === 'number') { if (isNaN(data.entries[key])) continue; string = neonRouge(sanitize(data.entries[key])); } else if (!isFailed) { string = sanitize(data.entries[key]); } if (string.length === 0) continue; const entry = data.type === 'warning' ? yellowBright(key) : redBright(key); if ( key === 'source' || key === 'output' || key === 'input' || key === 'file') { tui.Line(`${entry}${COL} ${space(key)}${underline(string + line + col)}`, gray); } else if (isFailed) { if (Array.isArray(data.entries[key])) { for (const fail of data.entries[key]) { tui.Line(`${entry}${COL} ${space(key)}${underline(fail)}`, gray); } } else { tui.Line(`${entry}${COL} ${space(key)}${underline(data.entries[key])}`, gray); } } else { tui.Line(`${entry}${COL} ${space(key)}${string}`, gray); } } if (data.stack === true) { tui.Newline().Line(Suffix.stack); } return tui.toString(); };