diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..ac6ca88 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,21 @@ +{ + "overrides": [ + { + "files": "*.ts", + "parser": "@typescript-eslint/parser", + "rules": { + "no-unused-vars": "off", + "no-useless-constructor": "off", + "@typescript-eslint/no-unused-vars": "error", + "@typescript-eslint/no-useless-constructor": "error" + } + } + ], + "parserOptions": { + "ecmaVersion": 2018, + "sourceType": "module" + }, + "plugins": [ + "@typescript-eslint/eslint-plugin" + ] +} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9c14fc9..fba38a3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -43,3 +43,27 @@ jobs: PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true - run: npm test - run: npm run coverage + esm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: 14 + - run: npm install + - run: npm run test:esm + deno: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: 14 + - run: npm install + - run: npm run compile + - uses: denolib/setup-deno@v2 + with: + deno-version: v1.x + - run: | + deno --version + deno test test/deno/cliui-test.ts diff --git a/.gitignore b/.gitignore index eef5570..0028f33 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ node_modules .nyc_output package-lock.json coverage - +build diff --git a/.nycrc b/.nycrc index 827f9a7..73cd631 100644 --- a/.nycrc +++ b/.nycrc @@ -7,7 +7,7 @@ "html", "text" ], - "lines": 98.0, - "branches": "98", - "statements": "98.0" + "lines": 99.0, + "branches": "95", + "statements": "99.0" } diff --git a/README.md b/README.md index b9a847f..65b5672 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,13 @@ easily create complex multi-column command-line-interfaces. ## Example ```js -var ui = require('cliui')() +const ui = require('cliui')() ui.div('Usage: $0 [command] [options]') ui.div({ text: 'Options:', - padding: [2, 0, 2, 0] + padding: [2, 0, 1, 0] }) ui.div( @@ -40,6 +40,32 @@ ui.div( console.log(ui.toString()) ``` +## Deno/ESM Support + +As of `v7` `cliui` supports [Deno](https://github.com/denoland/deno) and +[ESM](https://nodejs.org/api/esm.html#esm_ecmascript_modules): + +```typescript +import cliui from "https://deno.land/x/cliui/deno.ts"; + +const ui = cliui({}) + +ui.div('Usage: $0 [command] [options]') + +ui.div({ + text: 'Options:', + padding: [2, 0, 1, 0] +}) + +ui.div({ + text: "-f, --file", + width: 20, + padding: [0, 4, 0, 4] +}) + +console.log(ui.toString()) +``` + ## Layout DSL diff --git a/deno.ts b/deno.ts new file mode 100644 index 0000000..8adbc03 --- /dev/null +++ b/deno.ts @@ -0,0 +1,13 @@ +// Bootstrap cliui with CommonJS dependencies: +import { cliui, UIOptions, UI } from './build/lib/index.js' +import { wrap, stripAnsi } from './build/lib/string-utils.js' + +export default function ui (opts: UIOptions): UI { + return cliui(opts, { + stringWidth: (str: string) => { + return [...str].length + }, + stripAnsi, + wrap + }) +} diff --git a/index.mjs b/index.mjs new file mode 100644 index 0000000..bc7a022 --- /dev/null +++ b/index.mjs @@ -0,0 +1,13 @@ +// Bootstrap cliui with CommonJS dependencies: +import { cliui } from './build/lib/index.js' +import { wrap, stripAnsi } from './build/lib/string-utils.js' + +export default function ui (opts) { + return cliui(opts, { + stringWidth: (str) => { + return [...str].length + }, + stripAnsi, + wrap + }) +} diff --git a/lib/cjs.ts b/lib/cjs.ts new file mode 100644 index 0000000..bda4241 --- /dev/null +++ b/lib/cjs.ts @@ -0,0 +1,12 @@ +// Bootstrap cliui with CommonJS dependencies: +import { cliui, UIOptions } from './index.js' +const stringWidth = require('string-width') +const stripAnsi = require('strip-ansi') +const wrap = require('wrap-ansi') +export default function ui (opts: UIOptions) { + return cliui(opts, { + stringWidth, + stripAnsi, + wrap + }) +} diff --git a/index.js b/lib/index.ts similarity index 64% rename from index.js rename to lib/index.ts index e917b00..db5052e 100644 --- a/index.js +++ b/lib/index.ts @@ -1,26 +1,57 @@ '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) { +export interface UIOptions { + width: number; + wrap: boolean; + rows?: string[]; +} + +interface Column { + text: string; + width?: number; + align?: 'right'|'left'|'center', + padding: number[], + border?: boolean; +} + +interface ColumnArray extends Array { + span: boolean; +} + +interface Line { + hidden?: boolean; + text: string; + span?: boolean; +} + +interface Mixin { + stringWidth: Function; + stripAnsi: Function; + wrap: Function; +} + +export class UI { + width: number; + wrap: boolean; + rows: ColumnArray[]; + + constructor (opts: UIOptions) { this.width = opts.width this.wrap = opts.wrap this.rows = [] } - span (...args) { + span (...args: ColumnArray) { const cols = this.div(...args) cols.span = true } @@ -29,33 +60,32 @@ class UI { this.rows = [] } - div (...args) { + div (...args: (Column|string)[]): ColumnArray { if (args.length === 0) { this.div('') } - if (this.wrap && this._shouldApplyLayoutDSL(...args)) { - return this._applyLayoutDSL(args[0]) + if (this.wrap && this.shouldApplyLayoutDSL(...args) && typeof args[0] === 'string') { + return this.applyLayoutDSL(args[0]) } const cols = args.map(arg => { if (typeof arg === 'string') { - return this._colFromString(arg) + return this.colFromString(arg) } - return arg - }) + }) as ColumnArray this.rows.push(cols) return cols } - _shouldApplyLayoutDSL (...args) { + private shouldApplyLayoutDSL (...args: (Column|string)[]): boolean { return args.length === 1 && typeof args[0] === 'string' && /[\t\n]/.test(args[0]) } - _applyLayoutDSL (str) { + private applyLayoutDSL (str: string): ColumnArray { const rows = str.split('\n').map(row => row.split('\t')) let leftColumnWidth = 0 @@ -64,10 +94,10 @@ class UI { // 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) { + if (columns.length > 1 && mixin.stringWidth(columns[0]) > leftColumnWidth) { leftColumnWidth = Math.min( Math.floor(this.width * 0.5), - stringWidth(columns[0]) + mixin.stringWidth(columns[0]) ) } }) @@ -79,30 +109,30 @@ class UI { this.div(...columns.map((r, i) => { return { text: r.trim(), - padding: this._measurePadding(r), + padding: this.measurePadding(r), width: (i === 0 && columns.length > 1) ? leftColumnWidth : undefined - } + } as Column })) }) return this.rows[this.rows.length - 1] } - _colFromString (text) { + private colFromString (text: string): Column { return { text, - padding: this._measurePadding(text) + padding: this.measurePadding(text) } } - _measurePadding (str) { + private measurePadding (str: string): number[] { // measure padding without ansi escape codes - const noAnsi = stripAnsi(str) + const noAnsi = mixin.stripAnsi(str) return [0, noAnsi.match(/\s*$/)[0].length, 0, noAnsi.match(/^\s*/)[0].length] } - toString () { - const lines = [] + toString (): string { + const lines: Line[] = [] this.rows.forEach(row => { this.rowToString(row, lines) @@ -116,24 +146,25 @@ class UI { .join('\n') } - rowToString (row, lines) { - this._rasterize(row).forEach((rrow, r) => { + rowToString (row: ColumnArray, lines: Line[]) { + this.rasterize(row).forEach((rrow, r) => { let str = '' - rrow.forEach((col, c) => { + rrow.forEach((col: string, c: number) => { const { width } = row[c] // the width with padding. - const wrapWidth = this._negatePadding(row[c]) // the width without 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)) + if (wrapWidth > mixin.stringWidth(col)) { + ts += ' '.repeat(wrapWidth - mixin.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) + const fn = align[(row[c].align as 'right'|'center')] + ts = fn(ts, wrapWidth) + if (mixin.stringWidth(ts) < wrapWidth) { + ts += ' '.repeat((width || 0) - mixin.stringWidth(ts) - 1) } } @@ -153,7 +184,7 @@ class UI { // 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]) + str = this.renderInline(str, lines[lines.length - 1]) } }) @@ -169,10 +200,11 @@ class UI { // if the full 'source' can render in // the target line, do so. - _renderInline (source, previousLine) { - const leadingWhitespace = source.match(/^ */)[0].length + private renderInline (source: string, previousLine: Line) { + const match = source.match(/^ */) + const leadingWhitespace = match ? match[0].length : 0 const target = previousLine.text - const targetTextWidth = stringWidth(target.trimRight()) + const targetTextWidth = mixin.stringWidth(target.trimRight()) if (!previousLine.span) { return source @@ -194,9 +226,9 @@ class UI { return target.trimRight() + ' '.repeat(leadingWhitespace - targetTextWidth) + source.trimLeft() } - _rasterize (row) { - const rrows = [] - const widths = this._columnWidths(row) + private rasterize (row: ColumnArray) { + const rrows: string[][] = [] + const widths = this.columnWidths(row) let wrapped // word wrap all columns, and create @@ -205,14 +237,14 @@ class UI { // 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') + wrapped = mixin.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) + "'") + wrapped.unshift('.' + '-'.repeat(this.negatePadding(col) + 2) + '.') + wrapped.push("'" + '-'.repeat(this.negatePadding(col) + 2) + "'") } // add top and bottom padding. @@ -221,7 +253,7 @@ class UI { wrapped.push(...new Array(col.padding[bottom] || 0).fill('')) } - wrapped.forEach((str, r) => { + wrapped.forEach((str: string, r: number) => { if (!rrows[r]) { rrows.push([]) } @@ -241,8 +273,8 @@ class UI { return rrows } - _negatePadding (col) { - let wrapWidth = col.width + private negatePadding (col: Column) { + let wrapWidth = col.width || 0 if (col.padding) { wrapWidth -= (col.padding[left] || 0) + (col.padding[right] || 0) } @@ -254,10 +286,10 @@ class UI { return wrapWidth } - _columnWidths (row) { + private columnWidths (row: ColumnArray) { if (!this.wrap) { return row.map(col => { - return col.width || stringWidth(col.text) + return col.width || mixin.stringWidth(col.text) }) } @@ -288,7 +320,7 @@ class UI { } } -function addBorder (col, ts, style) { +function addBorder (col: Column, ts: string, style: string) { if (col.border) { if (/[.']-+[.']/.test(ts)) { return '' @@ -306,7 +338,7 @@ function addBorder (col, ts, style) { // calculates the minimum width of // a column, based on padding preferences. -function _minWidth (col) { +function _minWidth (col: Column) { const padding = col.padding || [] const minWidth = 1 + (padding[left] || 0) + (padding[right] || 0) if (col.border) { @@ -316,16 +348,17 @@ function _minWidth (col) { return minWidth } -function getWindowWidth () { +function getWindowWidth (): number { /* istanbul ignore next: depends on terminal */ if (typeof process === 'object' && process.stdout && process.stdout.columns) { return process.stdout.columns } + return 80 } -function alignRight (str, width) { +function alignRight (str: string, width: number): string { str = str.trim() - const strWidth = stringWidth(str) + const strWidth = mixin.stringWidth(str) if (strWidth < width) { return ' '.repeat(width - strWidth) + str @@ -334,9 +367,9 @@ function alignRight (str, width) { return str } -function alignCenter (str, width) { +function alignCenter (str: string, width: number): string { str = str.trim() - const strWidth = stringWidth(str) + const strWidth = mixin.stringWidth(str) /* istanbul ignore next */ if (strWidth >= width) { @@ -346,9 +379,11 @@ function alignCenter (str, width) { return ' '.repeat((width - strWidth) >> 1) + str } -module.exports = function (opts = {}) { +let mixin: Mixin +export function cliui (opts: Partial = {}, _mixin: Mixin) { + mixin = _mixin return new UI({ - width: opts.width || getWindowWidth() || /* istanbul ignore next */ 80, + width: opts.width || getWindowWidth(), wrap: opts.wrap !== false }) } diff --git a/lib/string-utils.ts b/lib/string-utils.ts new file mode 100644 index 0000000..23d78fd --- /dev/null +++ b/lib/string-utils.ts @@ -0,0 +1,30 @@ +// Minimal replacement for ansi string helpers "wrap-ansi" and "strip-ansi". +// to facilitate ESM and Deno modules. +// TODO: look at porting https://www.npmjs.com/package/wrap-ansi to ESM. + +// The npm application +// Copyright (c) npm, Inc. and Contributors +// Licensed on the terms of The Artistic License 2.0 +// See: https://github.com/npm/cli/blob/4c65cd952bc8627811735bea76b9b110cc4fc80e/lib/utils/ansi-trim.js +const ansi = new RegExp('\x1b(?:\\[(?:\\d+[ABCDEFGJKSTm]|\\d+;\\d+[Hfm]|' + +'\\d+;\\d+;\\d+m|6n|s|u|\\?25[lh])|\\w)', 'g') + +export function stripAnsi (str: string) { + return str.replace(ansi, '') +} + +export function wrap (str: string, width: number) { + const [start, end] = str.match(ansi) || ['', ''] + str = stripAnsi(str) + let wrapped = '' + for (let i = 0; i < str.length; i++) { + if (i !== 0 && (i % width) === 0) { + wrapped += '\n' + } + wrapped += str.charAt(i) + } + if (start && end) { + wrapped = `${start}${wrapped}${end}` + } + return wrapped +} diff --git a/package.json b/package.json index 90bfd23..1f4bab7 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,26 @@ "version": "6.0.0", "description": "easily create complex multi-column command-line-interfaces", "main": "index.js", + "exports": { + "import": "./index.mjs", + "require": "./build/index.cjs" + }, + "type": "module", + "module": "./index.mjs", "scripts": { - "fix": "standard --fix", - "test": "c8 mocha", - "posttest": "standard", - "coverage": "c8 report --check-coverage" + "check": "standardx '**/*.ts' && standardx '**/*.js' && standardx '**/*.cjs'", + "fix": "standardx --fix '**/*.ts' && standardx --fix '**/*.js' && standardx --fix '**/*.cjs'", + "pretest": "rimraf build && tsc -p tsconfig.test.json && cross-env NODE_ENV=test npm run build:cjs", + "test": "c8 mocha ./test/*.cjs", + "test:esm": "c8 mocha ./test/esm/cliui-test.mjs", + "postest": "check", + "posttest": "npm run check", + "coverage": "c8 report --check-coverage", + "precompile": "rimraf build", + "compile": "tsc", + "postcompile": "npm run build:cjs", + "build:cjs": "rollup -c", + "prepare": "npm run compile" }, "repository": { "type": "git", @@ -38,14 +53,27 @@ "wrap-ansi": "^7.0.0" }, "devDependencies": { + "@typescript-eslint/eslint-plugin": "^3.8.0", + "@typescript-eslint/parser": "^3.8.0", + "@wessberg/rollup-plugin-ts": "^1.3.2", "c8": "^7.3.0", "chai": "^4.2.0", "chalk": "^4.1.0", + "cross-env": "^7.0.2", + "eslint": "^7.6.0", + "eslint-plugin-import": "^2.22.0", + "eslint-plugin-node": "^11.1.0", + "gts": "^2.0.2", "mocha": "^8.1.1", - "standard": "^14.3.4" + "rimraf": "^3.0.2", + "rollup": "^2.23.1", + "standardx": "^5.0.0", + "typescript": "^3.9.7" }, "files": [ - "index.js" + "build", + "index.mjs", + "!*.d.ts" ], "engine": { "node": ">=10" diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..ec8a2ac --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,17 @@ +import ts from '@wessberg/rollup-plugin-ts' + +const output = { + format: 'cjs', + file: './build/index.cjs', + exports: 'default' +} + +if (process.env.NODE_ENV === 'test') output.sourcemap = true + +export default { + input: './lib/cjs.ts', + output, + plugins: [ + ts({ /* options */ }) + ] +} diff --git a/test/cliui.js b/test/cliui.cjs similarity index 97% rename from test/cliui.js rename to test/cliui.cjs index e9752a8..6dd86e7 100644 --- a/test/cliui.js +++ b/test/cliui.cjs @@ -8,7 +8,7 @@ require('chai').should() process.env.FORCE_COLOR = 1 const chalk = require('chalk') -const cliui = require('../') +const cliui = require('../build/index.cjs') const stripAnsi = require('strip-ansi') describe('cliui', () => { @@ -82,7 +82,7 @@ describe('cliui', () => { const ui = cliui({ width: 40 }) - const widths = ui._columnWidths([{}, {}, {}]) + const widths = ui.columnWidths([{}, {}, {}]) widths[0].should.equal(13) widths[1].should.equal(13) @@ -93,7 +93,7 @@ describe('cliui', () => { const ui = cliui({ width: 40 }) - const widths = ui._columnWidths([{ width: 20 }, {}, {}]) + const widths = ui.columnWidths([{ width: 20 }, {}, {}]) widths[0].should.equal(20) widths[1].should.equal(10) @@ -104,7 +104,7 @@ describe('cliui', () => { const ui = cliui({ width: 40 }) - const widths = ui._columnWidths([{}, { width: 10 }, {}]) + const widths = ui.columnWidths([{}, { width: 10 }, {}]) widths[0].should.equal(15) widths[1].should.equal(10) @@ -115,7 +115,7 @@ describe('cliui', () => { const ui = cliui({ width: 40 }) - const widths = ui._columnWidths([{ width: 20 }, { width: 12 }, {}]) + const widths = ui.columnWidths([{ width: 20 }, { width: 12 }, {}]) widths[0].should.equal(20) widths[1].should.equal(12) @@ -126,7 +126,7 @@ describe('cliui', () => { const ui = cliui({ width: 40 }) - const widths = ui._columnWidths([{ width: 30 }, { width: 30 }, { padding: [0, 2, 0, 1] }]) + const widths = ui.columnWidths([{ width: 30 }, { width: 30 }, { padding: [0, 2, 0, 1] }]) widths[0].should.equal(30) widths[1].should.equal(30) diff --git a/test/deno/cliui-test.ts b/test/deno/cliui-test.ts new file mode 100644 index 0000000..ce24068 --- /dev/null +++ b/test/deno/cliui-test.ts @@ -0,0 +1,51 @@ +/* global Deno */ + +import { + assert, + assertEquals +} from 'https://deno.land/std/testing/asserts.ts' +import cliui from '../../deno.ts' + +Deno.test("wraps text at 'width' if a single column is given", () => { + const ui = cliui({ + width: 10 + }) + + ui.div('i am a string that should be wrapped') + + ui.toString().split('\n').forEach((row: string) => { + assert(row.length <= 10) + }) +}) + +Deno.test('evenly divides text across columns if multiple columns are given', () => { + const ui = cliui({ + width: 40 + }) + + ui.div( + { text: 'i am a string that should be wrapped', width: 15 }, + 'i am a second string that should be wrapped', + 'i am a third string that should be wrapped' + ) + + // total width of all columns is <= + // the width cliui is initialized with. + ui.toString().split('\n').forEach((row: string) => { + assert(row.length <= 40) + }) + + // it should wrap each column appropriately. + // TODO: we should flesh out the Deno and ESM implementation + // such that it spreads words out over multiple columns appropriately: + const expected = [ + 'i am a string ti am a seconi am a third', + 'hat should be wd string tha string that', + 'rapped t should be should be w', + ' wrapped rapped' + ] + + ui.toString().split('\n').forEach((line: string, i: number) => { + assertEquals(line, expected[i]) + }) +}) diff --git a/test/esm/cliui-test.mjs b/test/esm/cliui-test.mjs new file mode 100644 index 0000000..f57d77d --- /dev/null +++ b/test/esm/cliui-test.mjs @@ -0,0 +1,47 @@ +import {ok as assert, strictEqual} from 'assert' +import cliui from '../../index.mjs' + +describe('ESM', () => { + it("wraps text at 'width' if a single column is given", () => { + const ui = cliui({ + width: 10 + }) + + ui.div('i am a string that should be wrapped') + + ui.toString().split('\n').forEach((row) => { + assert(row.length <= 10) + }) + }) + + it('evenly divides text across columns if multiple columns are given', () => { + const ui = cliui({ + width: 40 + }) + + ui.div( + { text: 'i am a string that should be wrapped', width: 15 }, + 'i am a second string that should be wrapped', + 'i am a third string that should be wrapped' + ) + + // total width of all columns is <= + // the width cliui is initialized with. + ui.toString().split('\n').forEach((row) => { + assert(row.length <= 40) + }) + + // it should wrap each column appropriately. + // TODO: we should flesh out the Deno and ESM implementation + // such that it spreads words out over multiple columns appropriately: + const expected = [ + 'i am a string ti am a seconi am a third', + 'hat should be wd string tha string that', + 'rapped t should be should be w', + ' wrapped rapped' + ] + ui.toString().split('\n').forEach((line, i) => { + strictEqual(line, expected[i]) + }) + }) +}) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d5d8c21 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "./node_modules/gts/tsconfig-google.json", + "compilerOptions": { + "outDir": "build", + "rootDir": ".", + "sourceMap": false, + "target": "es2017", + "moduleResolution": "node", + "module": "es2015" + }, + "include": [ + "lib/**/*.ts" + ], + "exclude": [ + "lib/cjs.ts" + ] +} \ No newline at end of file diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..bc078f4 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": true + } +} \ No newline at end of file