Files
securebit-chat/node_modules/cliui/index.js
lockbitchat 0f8399ec88 feat(security,ui): self-host React deps, Tailwind, fonts; strict CSP; local QR; better selection state
Replace CDN React/ReactDOM/Babel with local libs; remove Babel and inline scripts
Build Tailwind locally, add safelist; switch to assets/tailwind.css
Self-host Font Awesome and Inter (CSS + woff2); remove external font CDNs
Implement strict CSP (no unsafe-inline/eval; scripts/styles/fonts from self)
Extract inline handlers; move PWA scripts to external files
Add local QR code generation (qrcode lib) and remove api.qrserver.com
Improve SessionTypeSelector visual selection (highlighted background and ring)
Keep PWA working with service worker and offline assets
Refs: CSP hardening, offline-first, no external dependencies
2025-09-08 16:04:58 -04:00

355 lines
8.0 KiB
JavaScript

'use strict'
const stringWidth = require('string-width')
const stripAnsi = require('strip-ansi')
const wrap = require('wrap-ansi')
const align = {
right: alignRight,
center: alignCenter
}
const top = 0
const right = 1
const bottom = 2
const left = 3
class UI {
constructor (opts) {
this.width = opts.width
this.wrap = opts.wrap
this.rows = []
}
span (...args) {
const cols = this.div(...args)
cols.span = true
}
resetOutput () {
this.rows = []
}
div (...args) {
if (args.length === 0) {
this.div('')
}
if (this.wrap && this._shouldApplyLayoutDSL(...args)) {
return this._applyLayoutDSL(args[0])
}
const cols = args.map(arg => {
if (typeof arg === 'string') {
return this._colFromString(arg)
}
return arg
})
this.rows.push(cols)
return cols
}
_shouldApplyLayoutDSL (...args) {
return args.length === 1 && typeof args[0] === 'string' &&
/[\t\n]/.test(args[0])
}
_applyLayoutDSL (str) {
const rows = str.split('\n').map(row => row.split('\t'))
let leftColumnWidth = 0
// simple heuristic for layout, make sure the
// second column lines up along the left-hand.
// don't allow the first column to take up more
// than 50% of the screen.
rows.forEach(columns => {
if (columns.length > 1 && stringWidth(columns[0]) > leftColumnWidth) {
leftColumnWidth = Math.min(
Math.floor(this.width * 0.5),
stringWidth(columns[0])
)
}
})
// generate a table:
// replacing ' ' with padding calculations.
// using the algorithmically generated width.
rows.forEach(columns => {
this.div(...columns.map((r, i) => {
return {
text: r.trim(),
padding: this._measurePadding(r),
width: (i === 0 && columns.length > 1) ? leftColumnWidth : undefined
}
}))
})
return this.rows[this.rows.length - 1]
}
_colFromString (text) {
return {
text,
padding: this._measurePadding(text)
}
}
_measurePadding (str) {
// measure padding without ansi escape codes
const noAnsi = stripAnsi(str)
return [0, noAnsi.match(/\s*$/)[0].length, 0, noAnsi.match(/^\s*/)[0].length]
}
toString () {
const lines = []
this.rows.forEach(row => {
this.rowToString(row, lines)
})
// don't display any lines with the
// hidden flag set.
return lines
.filter(line => !line.hidden)
.map(line => line.text)
.join('\n')
}
rowToString (row, lines) {
this._rasterize(row).forEach((rrow, r) => {
let str = ''
rrow.forEach((col, c) => {
const { width } = row[c] // the width with padding.
const wrapWidth = this._negatePadding(row[c]) // the width without padding.
let ts = col // temporary string used during alignment/padding.
if (wrapWidth > stringWidth(col)) {
ts += ' '.repeat(wrapWidth - stringWidth(col))
}
// align the string within its column.
if (row[c].align && row[c].align !== 'left' && this.wrap) {
ts = align[row[c].align](ts, wrapWidth)
if (stringWidth(ts) < wrapWidth) {
ts += ' '.repeat(width - stringWidth(ts) - 1)
}
}
// apply border and padding to string.
const padding = row[c].padding || [0, 0, 0, 0]
if (padding[left]) {
str += ' '.repeat(padding[left])
}
str += addBorder(row[c], ts, '| ')
str += ts
str += addBorder(row[c], ts, ' |')
if (padding[right]) {
str += ' '.repeat(padding[right])
}
// if prior row is span, try to render the
// current row on the prior line.
if (r === 0 && lines.length > 0) {
str = this._renderInline(str, lines[lines.length - 1])
}
})
// remove trailing whitespace.
lines.push({
text: str.replace(/ +$/, ''),
span: row.span
})
})
return lines
}
// if the full 'source' can render in
// the target line, do so.
_renderInline (source, previousLine) {
const leadingWhitespace = source.match(/^ */)[0].length
const target = previousLine.text
const targetTextWidth = stringWidth(target.trimRight())
if (!previousLine.span) {
return source
}
// if we're not applying wrapping logic,
// just always append to the span.
if (!this.wrap) {
previousLine.hidden = true
return target + source
}
if (leadingWhitespace < targetTextWidth) {
return source
}
previousLine.hidden = true
return target.trimRight() + ' '.repeat(leadingWhitespace - targetTextWidth) + source.trimLeft()
}
_rasterize (row) {
const rrows = []
const widths = this._columnWidths(row)
let wrapped
// word wrap all columns, and create
// a data-structure that is easy to rasterize.
row.forEach((col, c) => {
// leave room for left and right padding.
col.width = widths[c]
if (this.wrap) {
wrapped = wrap(col.text, this._negatePadding(col), { hard: true }).split('\n')
} else {
wrapped = col.text.split('\n')
}
if (col.border) {
wrapped.unshift('.' + '-'.repeat(this._negatePadding(col) + 2) + '.')
wrapped.push("'" + '-'.repeat(this._negatePadding(col) + 2) + "'")
}
// add top and bottom padding.
if (col.padding) {
wrapped.unshift(...new Array(col.padding[top] || 0).fill(''))
wrapped.push(...new Array(col.padding[bottom] || 0).fill(''))
}
wrapped.forEach((str, r) => {
if (!rrows[r]) {
rrows.push([])
}
const rrow = rrows[r]
for (let i = 0; i < c; i++) {
if (rrow[i] === undefined) {
rrow.push('')
}
}
rrow.push(str)
})
})
return rrows
}
_negatePadding (col) {
let wrapWidth = col.width
if (col.padding) {
wrapWidth -= (col.padding[left] || 0) + (col.padding[right] || 0)
}
if (col.border) {
wrapWidth -= 4
}
return wrapWidth
}
_columnWidths (row) {
if (!this.wrap) {
return row.map(col => {
return col.width || stringWidth(col.text)
})
}
let unset = row.length
let remainingWidth = this.width
// column widths can be set in config.
const widths = row.map(col => {
if (col.width) {
unset--
remainingWidth -= col.width
return col.width
}
return undefined
})
// any unset widths should be calculated.
const unsetWidth = unset ? Math.floor(remainingWidth / unset) : 0
return widths.map((w, i) => {
if (w === undefined) {
return Math.max(unsetWidth, _minWidth(row[i]))
}
return w
})
}
}
function addBorder (col, ts, style) {
if (col.border) {
if (/[.']-+[.']/.test(ts)) {
return ''
}
if (ts.trim().length !== 0) {
return style
}
return ' '
}
return ''
}
// calculates the minimum width of
// a column, based on padding preferences.
function _minWidth (col) {
const padding = col.padding || []
const minWidth = 1 + (padding[left] || 0) + (padding[right] || 0)
if (col.border) {
return minWidth + 4
}
return minWidth
}
function getWindowWidth () {
/* istanbul ignore next: depends on terminal */
if (typeof process === 'object' && process.stdout && process.stdout.columns) {
return process.stdout.columns
}
}
function alignRight (str, width) {
str = str.trim()
const strWidth = stringWidth(str)
if (strWidth < width) {
return ' '.repeat(width - strWidth) + str
}
return str
}
function alignCenter (str, width) {
str = str.trim()
const strWidth = stringWidth(str)
/* istanbul ignore next */
if (strWidth >= width) {
return str
}
return ' '.repeat((width - strWidth) >> 1) + str
}
module.exports = function (opts = {}) {
return new UI({
width: opts.width || getWindowWidth() || /* istanbul ignore next */ 80,
wrap: opts.wrap !== false
})
}