@freeword/meta
Version:
Meta package for Freeword: exports all core types, constants, and utilities from the src/ directory.
189 lines (175 loc) • 9.88 kB
text/typescript
import _ /**/ from 'lodash'
import type { AnyBag } from '../types/index.ts'
import { ELLIPSIS_1GLYPH, ELLIPSIS_3DOTS } from '../Consts.ts'
import { inspectify } from './stringify.ts'
import { throwable } from './OutcomeUtils.ts'
import { scrubVoid } from './PropUtils.ts'
type StringMaybe = string | undefined | null
const BRIEF_STRINGIFIER = (_vv: any) => inspectify(_vv, { naked: true, maxlen: 80 })
export function snipjoin(arr: any[], { max = 8, joiner = ', ', stringifier = BRIEF_STRINGIFIER, yadayada = ', ...', shave = 1, ...rest }: toSentenceOpts = {}) {
return toSentence(arr, { max, joiner, stringifier, shave, yadayada, lastJoiner: joiner, pairJoiner: joiner, ...rest })
}
export function briefSentence(arr: readonly any[], briefSentenceOpts: toSentenceOpts = {}): string {
const { max = 8, joiner = ', ', stringifier = BRIEF_STRINGIFIER, yadayada = ', ...', shave = 1, ...rest } = briefSentenceOpts
return toSentence(arr, { max, joiner, stringifier, shave, yadayada, lastJoiner: joiner, pairJoiner: joiner, ...rest })
}
export interface toSentenceOpts {
joiner?: StringMaybe, conj?: string, lastJoiner?: StringMaybe, pairJoiner?: StringMaybe,
max?: number, min?: number, shave?: number, yadayada?: string, whoa?: string,
empty?: string, stringifier?: (val: any) => string,
}
export function toSentence(arr: any[] | AnyBag, opts: toSentenceOpts = {}): string {
const conj = opts.conj ?? 'and'
const joiner = opts.joiner ?? ', '
// const joinchar = joiner.trim
const {
max:_max = Infinity, min = 0, shave = 0, whoa = '', empty = '',
lastJoiner = `${joiner}${conj} `, pairJoiner = ` ${conj} `, yadayada = `${joiner}...${joiner}${conj} `,
stringifier = BRIEF_STRINGIFIER, joiner:_j, conj:_c, ...rest
} = opts
const max = _.clamp(Number(_max), _.clamp(Number(min), 0, Infinity), Number(_max))
if (! _.isEmpty(rest)) { throw throwable(`Need joiner, pairJoiner, lastJoiner, stringifier`, 'blanks', { keys: _.keys(rest) }) }
//
const joinable: manyAndLastResult = manyAndLast(_.values(arr), { max, shave }) as manyAndLastResult
const { iam } = joinable
if (iam === 'void') { return empty }
if (iam === 'xnil') { return empty + whoa }
if (iam === 'solo') { return stringifier(joinable.first) }
if (iam === 'xone') { return stringifier(joinable.first) + whoa }
const last = stringifier(joinable.last)
if (iam === 'pair') { return stringifier(joinable.first) + (pairJoiner ?? '') + last }
if (iam === 'xtwo') { return stringifier(joinable.first) + (yadayada ?? '') + last + whoa }
const many = _.map(joinable.many, stringifier)
if (iam === 'many') { return many.join(joiner) + (lastJoiner ?? '') + last }
// extra elements present. Even with two we want to indicate the overflow
return many.join(joiner) + (yadayada ?? '') + last + whoa
}
const ManyAndLastKeys = ['void', 'solo', 'pair', 'many', 'xnil', 'xone', 'xtwo', 'xtra'] as const
type ManyAndLastFrames<VT = any> = (
| { iam: 'void' } // the collection was empty
| { iam: 'solo', first: VT } // there was only one entry, stored in `first`
| { iam: 'pair', first: VT, last: VT } // there were exactly two entries, and two is less than the cap
| { iam: 'many', many: VT[], last: VT } // there were more than two entries but less than the cap; `last` has the last element, `many` has the others.
| { iam: 'xnil' } // an empty result was requested (but the collection was not empty)
| { iam: 'xone', first: VT } // a single result was requested, stored in `first` (but more than one was present)
| { iam: 'xtwo', first: VT, last: VT } // two entries were requested (but more were present)
| { iam: 'xtra', many: VT[], last: VT } // there were more than the cap; here are the first many and the last one (total count = max - shave)
)
export interface ManyAndLastMerged<VT = any> {
iam: typeof ManyAndLastKeys[number],
many?: VT[] | undefined,
first?: VT | undefined,
last?: VT | undefined,
}
type manyAndLastResult<VT = any> = { iam: typeof ManyAndLastKeys[number], many: VT[], first: VT, last: VT }
// takes up to `max` entries from tne collection
export function manyAndLast<VT = any>(clxn: VT[], { max = Infinity, shave = 0 }: { max?: number, shave?: number } = {}): ManyAndLastFrames<VT> {
const arr: VT[] = _.values(clxn)
const arrlen: number = arr.length
if (arrlen <= max) {
if (! (arrlen > 0)) { return { iam: 'void' } }
if (arrlen === 1) { return { iam: 'solo', first: arr[0]! } }
const last: VT = arr.pop() as VT
if (arrlen === 2) { return { iam: 'pair', first: arr[0]!, last } }
return { iam: 'many', many: arr, last }
}
if (! (max >= 1)) { return { iam: 'xnil' } }
const cap = (shave > 0) ? _.clamp(max - shave, 1, max) : max
if (cap <= 1) { return { iam: 'xone', first: arr[0]! } }
const last = _.last(arr) as VT
if (cap === 2) { return { iam: 'xtwo', first: arr[0]!, last } }
return { iam: 'xtra', many: _.take(arr, cap - 1), last }
}
export interface SomeManyAndLastFrame<VT = any> extends ManyAndLastMerged<VT> {
/** the shortened list (first, many, and last) with no joiners inserted; if shaved, there might be fewer than `max` */
some: VT[]
/** the body (first/many), including the last if no reduction was done */
body: VT[]
/** if reduced, an array with the 'extra' element */
tail: VT[]
/** the reduced number of elements */
postsize: number
/** true if there was a 'last' element to ellipsize */
ellipsize: boolean
}
export function someManyAndLast<VT = any>(clxn: VT[], opts: { max?: number, shave?: number } = {}): SomeManyAndLastFrame<VT> {
const result = manyAndLast(clxn, opts)
const parts = result as ManyAndLastMerged<VT>
const body: VT[] = parts.many ? parts.many : parts.first ? [parts.first] : []
const tail: VT[] = []
const ellipsize = (parts.iam === 'xtra' || parts.iam === 'xtwo')
// const some: VT[] = parts.many ?? []
// if ('first' in parts) { some.unshift(parts.first as VT) }
// if ('last' in parts) { some.push(parts.last as VT) }
if ('last' in parts) {
if (ellipsize) { tail.push(parts.last as VT) } else { body.push(parts.last as VT) }
}
const some = [...body, ...tail]
const postsize = some.length
return { ...result, some, body, tail, postsize, ellipsize }
}
export function hardcapList<VT = any>(clxn: VT[], opts: { max?: number, shave?: number, yadayada?: string } = {}): VT[] {
const copts = { max: 7, shave: 1, yadayada: '…', ...opts }
const result = manyAndLast(clxn, copts)
const { iam } = result
switch (iam) {
case 'void': return clxn
case 'solo': return clxn
case 'pair': return clxn
case 'xnil': return [] as VT[]
case 'xone': return [result.first]
case 'many': return clxn
case 'xtwo': return [result.first, copts.yadayada as VT, result.last]
case 'xtra': return [...result.many, copts.yadayada as VT, result.last]
default: throw throwable('Unreachable state from manyAndLast', 'unknownTag', { result, clxn, ManyAndLastKeys })
}
}
export interface shortenOpts { tail?: string }
// sugar for calling shorten with the fancy `…` single character rather
// than three '...' dots. Note: `…` will have catastrophic effects on the
// size of an SMS, do not do that.
export function shortenWithEllipsis(str: string, maxlen = 29): string {
return shorten(str, maxlen, { tail: ELLIPSIS_1GLYPH })
}
export function shorten(str: string, maxlen = 29, opts: shortenOpts = {}): string {
const { tail = ELLIPSIS_3DOTS } = opts
if (! str) { return '' }
if (str.trim().length <= maxlen) { return str.trim() } // if it's already legal return it
//
if (maxlen <= 16 || (! tail)) { return str.slice(0, maxlen).trim() } // Don't get fancy with ellipsis if it's short or no ellipsis is to be used
//
const taillen = tail.length
const breaklen = maxlen - (12 + taillen)
const most = str.slice(0, breaklen) // Keep most of it around, and
let rest = str.slice(breaklen, maxlen - taillen) // shorten the tail to one longer than maxlen + ellipsis
if (/\w$/.test(rest)) { // if the last character is part of a word,
rest = rest.replace(/\w+$/, '') // chop off the partial word
}
rest = rest.replace(/\W+$/, '') // and in any case trim non-word characters from the end
const shorter = `${most}${rest}${tail}` // finally, attach an ellipsis
//
return shorter.trim()
}
// Strip blank/undefined/null strings from the args and string-join them with sep
export function smush(sep: string, ...rest: (string | undefined | null | number)[]): string {
return scrubVoid(rest).join(sep)
}
export function qt(val: string) { return `'${val.replaceAll('\'', '\\\'')}'` }
export function dqt(val: string) { return `"${val.replaceAll('"', '\\"')}"` }
export function qtc(val: string) { return comma(qt(val)) }
export function comma(val: string) { return val + ',' }
function indentStr(by: number | string = 2) {
return _.isNumber(by) ? _.repeat(' ', by) : by
}
export function indent(text: string, by: number | string = 2) {
const indenting = indentStr(by)
return _.trim(text)
.split(/\n/g)
.map((line) => (indenting + line).replace(/^ +$/, "")) // replace lines of all spaces with empty string; tabs, \v, other whitespace are not touched
.join("\n")
}
/** remove the leading indent from each line of the text */
export function dedent(text: string, by: number = 2) {
const indenting = indentStr(by)
return text.replace(indenting, '').replaceAll('\n' + indenting, '\n')
}