@lmctl-ai/lmformat 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/index.d.ts +67 -0
- package/index.js +405 -0
- package/package.json +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lattice Systems LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# lmformat
|
|
2
|
+
|
|
3
|
+
Dependency-free Node.js library for CLI output formatting: automatically sized
|
|
4
|
+
tables, compact counts, durations, and timestamps.
|
|
5
|
+
Install with `npm install @lmctl-ai/lmformat`.
|
|
6
|
+
|
|
7
|
+
Pass an array of rows; each row is an array of cells. No column widths are needed.
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
const { formatTable, printTable } = require('@lmctl-ai/lmformat');
|
|
11
|
+
// ESM also supports: import { formatTable, printTable } from '@lmctl-ai/lmformat';
|
|
12
|
+
|
|
13
|
+
const rows = [
|
|
14
|
+
['triage.lmctl', 'Triage', 'done', 5, '25s'],
|
|
15
|
+
['math.lmctl', 'Lead', 'done', 120, '2s'],
|
|
16
|
+
];
|
|
17
|
+
printTable(rows, {
|
|
18
|
+
headers: ['team', 'alias', 'status', 'msgs', 'time spent'],
|
|
19
|
+
align: ['left', 'left', 'left', 'right', 'right'],
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
team alias status msgs time spent
|
|
25
|
+
triage.lmctl Triage done 5 25s
|
|
26
|
+
math.lmctl Lead done 120 2s
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`formatTable(rows, { headers, align }?)` returns a string without a final newline.
|
|
30
|
+
`printTable(rows, { headers, align, stream }?)` writes it with a final newline to
|
|
31
|
+
`process.stdout` or a supplied writable stream. Empty output writes nothing.
|
|
32
|
+
Headers are optional. You can also include a header as the first row.
|
|
33
|
+
`align` optionally sets each column to `'left'` (default) or `'right'`;
|
|
34
|
+
numeric columns read best right-aligned. Headers follow their column's alignment.
|
|
35
|
+
|
|
36
|
+
## Measuring and rendering separately
|
|
37
|
+
|
|
38
|
+
`formatTable` measures and renders in one step. To align SEVERAL tables on one
|
|
39
|
+
shared grid — for example per-section tables under standalone headings — split
|
|
40
|
+
the two steps:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
const { measureColumns, renderGrid } = require('@lmctl-ai/lmformat');
|
|
44
|
+
|
|
45
|
+
const running = [['math.lmctl:Lead', 'exec', 'running 16h ago']];
|
|
46
|
+
const idle = [
|
|
47
|
+
['triage.lmctl:Triage', 'chat', 'ran 24s, idle 5m ago'],
|
|
48
|
+
['or.lmctl:Lead', 'chat', 'ran 5s, idle 6m ago'],
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const widths = measureColumns([running, idle]); // one width set across all grids
|
|
52
|
+
console.log(renderGrid(running, { widths, margin: 2 }));
|
|
53
|
+
console.log(renderGrid(idle, { widths, margin: 2 }));
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
math.lmctl:Lead exec running 16h ago
|
|
58
|
+
triage.lmctl:Triage chat ran 24s, idle 5m ago
|
|
59
|
+
or.lmctl:Lead chat ran 5s, idle 6m ago
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`measureColumns(grids, { max }?)` takes an array of grids; a grid is a rows array or
|
|
63
|
+
`{ rows, headers }`. It returns the display width of each column across all
|
|
64
|
+
grids — simply the widest cell, headers included; pass `max` (an array of
|
|
65
|
+
per-column caps, `null`/`undefined` entries uncapped) to bound chosen columns.
|
|
66
|
+
`renderGrid(rows, { widths, headers, align, margin }?)` renders one grid
|
|
67
|
+
with explicit widths; a non-trailing cell wider than its width is truncated
|
|
68
|
+
with an ellipsis (pass `truncate: false` to let it overflow). Trailing cells
|
|
69
|
+
are open-ended by default; `truncateTrailing: true` bounds them as well.
|
|
70
|
+
`margin` (a number of spaces or a string) indents every rendered line, so
|
|
71
|
+
section grids can sit under their headings without caller-side prefixing.
|
|
72
|
+
Omitting `widths` makes `renderGrid` measure its own rows — exactly what
|
|
73
|
+
`formatTable` does; `max` caps that self-measurement (passing both `widths`
|
|
74
|
+
and `max` throws). Measure globally and render per section, or measure per
|
|
75
|
+
section — the caller chooses.
|
|
76
|
+
|
|
77
|
+
All rows are loaded before formatting. Each column takes the maximum cell length
|
|
78
|
+
across headers and data, capped per column by `max` when given; cells are
|
|
79
|
+
right-padded with spaces and columns have a
|
|
80
|
+
two-space gap. Trailing empty cells add no padding. Ragged rows are accepted;
|
|
81
|
+
`null`, `undefined`, and missing cells become empty strings. Other values use
|
|
82
|
+
`String(value)`. Inputs are not modified. Long text is never wrapped; cells
|
|
83
|
+
over their column's width (measured or capped) are truncated with an ellipsis,
|
|
84
|
+
except trailing cells unless `truncateTrailing: true`. `truncate: false`
|
|
85
|
+
disables all truncation.
|
|
86
|
+
|
|
87
|
+
## Column widths and caps (manual, per call)
|
|
88
|
+
|
|
89
|
+
One giant cell should not stretch a whole column — and no statistics should
|
|
90
|
+
decide what "giant" means. The widest cell is the default, which callers can override.
|
|
91
|
+
When you have inspected real output and know a column should never exceed a
|
|
92
|
+
width, say so: `measureColumns(grids, { max: [26, null, 20] })` caps column 0
|
|
93
|
+
at 26 and column 2 at 20, leaving column 1 uncapped. Caps also work through
|
|
94
|
+
`renderGrid`/`formatTable`/`printTable` when they self-measure. A cap smaller
|
|
95
|
+
than the header caps the header too (you own both); a cap on the trailing
|
|
96
|
+
column takes effect only with `truncateTrailing: true`. When
|
|
97
|
+
rendering, a non-trailing cell wider than its column is truncated to the
|
|
98
|
+
width with a trailing `…`; pass `truncate: false` to let such cells overflow
|
|
99
|
+
instead.
|
|
100
|
+
|
|
101
|
+
To override just one column, measure first and edit that entry. This can either
|
|
102
|
+
narrow or widen the column while leaving the other measurements intact:
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
const widths = measureColumns([running, idle]);
|
|
106
|
+
widths[1] = 20;
|
|
107
|
+
console.log(renderGrid(running, { widths }));
|
|
108
|
+
console.log(renderGrid(idle, { widths }));
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
To bound a long trailing reason, opt into truncating the last populated cell
|
|
112
|
+
of each row. This also applies to headers, ragged rows, and single-column tables:
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
const rows = [['math', 'a very long reason']];
|
|
116
|
+
const widths = measureColumns([rows]);
|
|
117
|
+
widths[1] = 10;
|
|
118
|
+
renderGrid(rows, { widths, truncateTrailing: true }); // 'math a very lo…'
|
|
119
|
+
// The same result using automatic measurement with a cap:
|
|
120
|
+
formatTable(rows, { max: [null, 10], truncateTrailing: true });
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`truncateTrailing` defaults to `false` and works in `renderGrid`, `formatTable`,
|
|
124
|
+
and `printTable`. It uses the existing column width and `ellipsis` option.
|
|
125
|
+
`truncate: false` takes precedence. Zero or missing explicit widths remain
|
|
126
|
+
unconstrained. Left-aligned trailing cells stay unpadded; right alignment still
|
|
127
|
+
pads on the left. This is single-line truncation, not wrapping within cells.
|
|
128
|
+
|
|
129
|
+
The truncation mark itself is an option: `ellipsis` defaults to `…`; pass
|
|
130
|
+
`'...'` for ASCII-only terminals or `''` for a hard cut. A mark wider than the
|
|
131
|
+
column falls back to `…`. Like the bar glyphs (`fill`/`empty`/`unknown`), every
|
|
132
|
+
baked-in glyph is a caller option whose default keeps existing output unchanged.
|
|
133
|
+
|
|
134
|
+
Cells must be plain single-line text: tabs, newlines, ANSI escape sequences, and
|
|
135
|
+
other terminal control characters are rejected (use `escapeCell` to sanitize
|
|
136
|
+
untrusted text first). Widths are terminal display widths: East Asian
|
|
137
|
+
wide/fullwidth characters and most emoji count double, combining marks and
|
|
138
|
+
zero-width characters count zero (a pragmatic zero-dependency approximation,
|
|
139
|
+
not a full UAX #11 implementation). A terminal
|
|
140
|
+
narrower than the output may wrap lines.
|
|
141
|
+
|
|
142
|
+
Requires Node.js 18 or newer. Run `npm test` for alignment tests, `npm run check`
|
|
143
|
+
for syntax checks, and `npm run example` for the complete session example.
|
|
144
|
+
|
|
145
|
+
## Relative timestamps
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
const { formatRelativeTime, printTable } = require('@lmctl-ai/lmformat');
|
|
149
|
+
const now = '2026-09-25T20:00:00Z'; // Omit to use Date.now().
|
|
150
|
+
printTable([
|
|
151
|
+
['weekly', formatRelativeTime('2026-09-28T20:00:00Z', { now })],
|
|
152
|
+
['session', formatRelativeTime('2026-09-25T20:40:00Z', { now })],
|
|
153
|
+
['missing', formatRelativeTime('unknown', { now })],
|
|
154
|
+
], { headers: ['limit', 'resets in'] });
|
|
155
|
+
// limit resets in
|
|
156
|
+
// weekly 3d
|
|
157
|
+
// session 40m
|
|
158
|
+
// missing unknown
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`formatRelativeTime(timestamp, { now }?)` accepts a Date, an epoch timestamp in
|
|
162
|
+
milliseconds, or a date string accepted by `Date.parse`. Prefer ISO 8601 strings
|
|
163
|
+
with an explicit timezone. It returns the largest whole unit (`w`, `d`, `h`,
|
|
164
|
+
`m`, `s`), rounding down: 2 days and 20 hours becomes `2d`. Days mean 24 hours;
|
|
165
|
+
weeks mean 7 days.
|
|
166
|
+
Future timestamps return `3d`; past timestamps return `3d ago`; differences below
|
|
167
|
+
one second return `now`. Missing or invalid timestamps return `unknown`.
|
|
168
|
+
An invalid explicit `now` throws a TypeError. Capture `now` once when formatting
|
|
169
|
+
multiple timestamps so the report uses a consistent reference time.
|
|
170
|
+
|
|
171
|
+
Run `npm run example:ratelimit` for the supplied provider and rate-limit data,
|
|
172
|
+
including read ages, reset countdowns, credits, and exhausted status. This example
|
|
173
|
+
uses the fixed snapshot time `2026-09-25T20:33:47Z` for reproducible output.
|
|
174
|
+
Transform timestamp cells before passing rows to the table formatter; widths
|
|
175
|
+
are calculated from the resulting text automatically.
|
|
176
|
+
|
|
177
|
+
## Counts, durations, and clocks
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
const {
|
|
181
|
+
formatCount, formatDuration, formatPercent, formatClock, formatLocalTimestamp,
|
|
182
|
+
formatBar, formatTimer,
|
|
183
|
+
} = require('@lmctl-ai/lmformat');
|
|
184
|
+
|
|
185
|
+
formatCount(960_462); // "960K" (>= 1M keeps one decimal: "15.8M")
|
|
186
|
+
formatDuration(7_500_000); // "2h 5m" (up to two units: "3m 12s", "1w 2d")
|
|
187
|
+
formatDuration(788_400_000, { maxUnit: "d" }); // "9d 3h" (cap the largest unit)
|
|
188
|
+
formatDuration(180_180_000, { style: "compact" }); // "2d02h03m" (dense, no seconds)
|
|
189
|
+
formatPercent(0.16); // "16%"
|
|
190
|
+
formatBar(0.16); // "## " (bare fill, no frame)
|
|
191
|
+
formatBar(0.5, { width: 6, fill: '█', empty: '░' }); // "███░░░"
|
|
192
|
+
formatBar(null, { width: 3, unknown: '·' }); // "···"
|
|
193
|
+
formatTimer(2_172_000); // "00:36:12" ("5d 18:24:33" past 24h)
|
|
194
|
+
formatClock(); // "09:05:03" local wall clock, now or given time
|
|
195
|
+
formatLocalTimestamp(); // "2026-01-02 09:05:03" local date and time
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`formatCount` accepts a finite number and returns `unknown` otherwise. It rounds
|
|
199
|
+
symmetrically for negative values and promotes units after rounding, so
|
|
200
|
+
`999_999` becomes `"1.0M"` rather than `"1000K"`.
|
|
201
|
+
`formatDuration` takes milliseconds, clamps negatives to `0s`, and returns
|
|
202
|
+
`unknown` for non-finite input. `formatPercent` takes a fraction (`0.16` for 16%)
|
|
203
|
+
and returns `?%` for non-finite input — deliberately not `unknown`, since the
|
|
204
|
+
inline `used ?% remaining ?%` context wants the unit suffix kept. `formatClock` and `formatLocalTimestamp`
|
|
205
|
+
accept a Date, epoch milliseconds, or a parseable date string (default: now),
|
|
206
|
+
and return `unknown` for invalid input.
|
|
207
|
+
|
|
208
|
+
`formatBar` defaults to width 10, fill `#`, empty space, and unknown `?`.
|
|
209
|
+
Each custom glyph must be a single character with terminal display width 1;
|
|
210
|
+
wide emoji/CJK, combining marks, control characters, and multi-character strings
|
|
211
|
+
are rejected. Fractions still round to the nearest filled cell and clamp to
|
|
212
|
+
0–1. Non-finite input repeats the `unknown` glyph across the bar.
|
|
213
|
+
|
|
214
|
+
MIT licensed. Project homepage: [lmctl.com](https://lmctl.com).
|
|
215
|
+
|
|
216
|
+
## Publishing
|
|
217
|
+
|
|
218
|
+
CI builds and tests pushes to `main` and pull requests on Node 18, 20, 22, and 24.
|
|
219
|
+
It also installs the built tarball and checks CommonJS and ESM imports.
|
|
220
|
+
`npm run build` checks syntax and produces `release/lmctl-ai-lmformat-VERSION.tgz`.
|
|
221
|
+
Only the library, package metadata, README, and license are included.
|
|
222
|
+
|
|
223
|
+
Publishing uses [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/)
|
|
224
|
+
from GitHub Actions with provenance. No npm token is stored in GitHub.
|
|
225
|
+
|
|
226
|
+
One-time setup by an npm account with access to the `@lmctl-ai` scope:
|
|
227
|
+
|
|
228
|
+
1. Run `npm login`, then `npm ci && npm test && npm run build` and
|
|
229
|
+
`npm publish ./release/lmctl-ai-lmformat-0.1.0.tgz --access public` to create
|
|
230
|
+
the first package version. Complete npm's authentication/2FA prompt if requested.
|
|
231
|
+
2. On npmjs.com, open `@lmctl-ai/lmformat` → Settings → Trusted Publisher and
|
|
232
|
+
select GitHub Actions. Set organization to `lmctl-ai`, repository to `lmformat`,
|
|
233
|
+
and workflow filename to `publish.yml`. Leave environment blank and allow
|
|
234
|
+
direct publishing (`npm publish`).
|
|
235
|
+
3. Subsequent versions publish automatically when their matching version tag is
|
|
236
|
+
pushed. The workflow tests and builds again before publishing with provenance.
|
|
237
|
+
|
|
238
|
+
For example, after committing changes on `main`:
|
|
239
|
+
|
|
240
|
+
```sh
|
|
241
|
+
npm version patch -m 'Release %s for npm consumers'
|
|
242
|
+
git push origin main
|
|
243
|
+
git push origin --tags
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The tag must equal `v` plus the version in `package.json`. A tag for an already
|
|
247
|
+
published version cannot republish it. No version is changed by CI itself.
|
|
248
|
+
For a build and publish dry run, use the Publish workflow's **Run workflow** button
|
|
249
|
+
with `dry_run` checked. To retry an unpublished tagged version, select that tag
|
|
250
|
+
and uncheck `dry_run`. Real manual publishes from branch refs are rejected.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export type TableCell = string | number | boolean | null | undefined;
|
|
2
|
+
export type ColumnAlign = 'left' | 'right';
|
|
3
|
+
|
|
4
|
+
export interface TableOptions {
|
|
5
|
+
headers?: TableCell[];
|
|
6
|
+
align?: ColumnAlign[];
|
|
7
|
+
/** Truncate over-width cells (default true). False disables all truncation. */
|
|
8
|
+
truncate?: boolean;
|
|
9
|
+
/** Also truncate the last populated cell of each row (default false).
|
|
10
|
+
* Uses its column width and ellipsis; requires truncate to be enabled. */
|
|
11
|
+
truncateTrailing?: boolean;
|
|
12
|
+
/** Leading indent for every line: a number of spaces or a string. */
|
|
13
|
+
margin?: number | string;
|
|
14
|
+
/** Per-column width caps from manual inspection; null/undefined entries
|
|
15
|
+
* stay uncapped. Over-width non-trailing cells truncate with an ellipsis. */
|
|
16
|
+
max?: Array<number | null | undefined>;
|
|
17
|
+
/** Truncation mark (default '…'); '' gives a hard cut. A mark wider than
|
|
18
|
+
* the column falls back to '…'. */
|
|
19
|
+
ellipsis?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Grid {
|
|
23
|
+
rows: TableCell[][];
|
|
24
|
+
headers?: TableCell[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RenderGridOptions extends TableOptions {
|
|
28
|
+
widths?: number[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface MeasureOptions {
|
|
32
|
+
/** Per-column width caps from manual inspection; null/undefined entries
|
|
33
|
+
* stay uncapped. */
|
|
34
|
+
max?: Array<number | null | undefined>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PrintTableOptions extends TableOptions {
|
|
38
|
+
stream?: { write(text: string): void };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function measureColumns(grids: Array<Grid | TableCell[][]>, options?: MeasureOptions): number[];
|
|
42
|
+
export function renderGrid(rows: TableCell[][], options?: RenderGridOptions): string;
|
|
43
|
+
export function formatTable(rows: TableCell[][], options?: TableOptions): string;
|
|
44
|
+
export function printTable(rows: TableCell[][], options?: PrintTableOptions): void;
|
|
45
|
+
|
|
46
|
+
export type TimestampInput = Date | number | string;
|
|
47
|
+
|
|
48
|
+
export function formatRelativeTime(value: TimestampInput, options?: { now?: TimestampInput }): string;
|
|
49
|
+
export function formatCount(value: number | null | undefined): string;
|
|
50
|
+
export type DurationUnit = 'w' | 'd' | 'h' | 'm' | 's';
|
|
51
|
+
|
|
52
|
+
export function formatDuration(milliseconds: number | null | undefined, options?: { maxUnit?: DurationUnit; style?: 'spaced' | 'compact' }): string;
|
|
53
|
+
export function formatClock(value?: TimestampInput): string;
|
|
54
|
+
export function formatLocalTimestamp(value?: TimestampInput): string;
|
|
55
|
+
export function formatPercent(fraction: number | null | undefined): string;
|
|
56
|
+
export interface BarOptions {
|
|
57
|
+
width?: number;
|
|
58
|
+
/** Single character of display width 1 (default '#'). */
|
|
59
|
+
fill?: string;
|
|
60
|
+
/** Single character of display width 1 (default ' '). */
|
|
61
|
+
empty?: string;
|
|
62
|
+
/** Single character of display width 1 for non-finite input (default '?'). */
|
|
63
|
+
unknown?: string;
|
|
64
|
+
}
|
|
65
|
+
export function formatBar(fraction: number | null | undefined, options?: BarOptions): string;
|
|
66
|
+
export function formatTimer(milliseconds: number | null | undefined): string;
|
|
67
|
+
export function escapeCell(value: unknown): string;
|
package/index.js
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function normalizeRow(row) {
|
|
4
|
+
if (!Array.isArray(row)) {
|
|
5
|
+
throw new TypeError('Each row and headers must be an array of cells');
|
|
6
|
+
}
|
|
7
|
+
return Array.from(row, (value) => {
|
|
8
|
+
const cell = value == null ? '' : String(value);
|
|
9
|
+
if (/[\x00-\x1f\x7f-\x9f]/u.test(cell)) {
|
|
10
|
+
throw new TypeError('Cells must be single-line text without terminal control characters');
|
|
11
|
+
}
|
|
12
|
+
return cell;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeAlign(align) {
|
|
17
|
+
if (align === undefined) return [];
|
|
18
|
+
if (!Array.isArray(align)) {
|
|
19
|
+
throw new TypeError('align must be an array of "left" or "right" per column');
|
|
20
|
+
}
|
|
21
|
+
return align.map((value) => {
|
|
22
|
+
if (value !== 'left' && value !== 'right') {
|
|
23
|
+
throw new TypeError('align entries must be "left" or "right"');
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Approximate terminal display width (zero-dependency wcwidth): combining
|
|
30
|
+
* marks and zero-width characters add nothing; East Asian wide/fullwidth
|
|
31
|
+
* code points and most emoji count double. Close enough for column
|
|
32
|
+
* alignment; not a full UAX #11 implementation. */
|
|
33
|
+
function displayWidth(text) {
|
|
34
|
+
let width = 0;
|
|
35
|
+
for (const char of text) {
|
|
36
|
+
const cp = char.codePointAt(0);
|
|
37
|
+
if (
|
|
38
|
+
(cp >= 0x0300 && cp <= 0x036f) || // combining diacritical marks
|
|
39
|
+
(cp >= 0x1ab0 && cp <= 0x1aff) || // combining diacriticals extended
|
|
40
|
+
(cp >= 0x1dc0 && cp <= 0x1dff) || // combining diacriticals supplement
|
|
41
|
+
(cp >= 0x20d0 && cp <= 0x20ff) || // combining marks for symbols
|
|
42
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
|
|
43
|
+
(cp >= 0xfe20 && cp <= 0xfe2f) || // combining half marks
|
|
44
|
+
cp === 0x200b || // zero-width space
|
|
45
|
+
(cp >= 0xe0100 && cp <= 0xe01ef) // variation selectors supplement
|
|
46
|
+
) continue;
|
|
47
|
+
if (
|
|
48
|
+
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
49
|
+
(cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, Kangxi, ideographic
|
|
50
|
+
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK compatibility
|
|
51
|
+
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK extension A
|
|
52
|
+
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK unified ideographs
|
|
53
|
+
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
|
|
54
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
|
|
55
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
|
|
56
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) || // CJK compatibility forms
|
|
57
|
+
(cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
|
|
58
|
+
(cp >= 0xffe0 && cp <= 0xffe6) || // fullwidth signs
|
|
59
|
+
(cp >= 0x2600 && cp <= 0x27bf) || // misc symbols + dingbats
|
|
60
|
+
(cp >= 0x1f000 && cp <= 0x1faff) || // emoji (incl. transport, supplemental)
|
|
61
|
+
(cp >= 0x20000 && cp <= 0x3fffd) // CJK extensions B+
|
|
62
|
+
) {
|
|
63
|
+
width += 2;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
width += 1;
|
|
67
|
+
}
|
|
68
|
+
return width;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function padDisplay(cell, width, right) {
|
|
72
|
+
const padding = ' '.repeat(Math.max(0, width - displayWidth(cell)));
|
|
73
|
+
return right ? padding + cell : cell + padding;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeGrid(grid) {
|
|
77
|
+
if (Array.isArray(grid)) return { rows: grid };
|
|
78
|
+
if (grid !== null && typeof grid === 'object' && Array.isArray(grid.rows)) return grid;
|
|
79
|
+
throw new TypeError('each grid must be a rows array or { rows, headers }');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeEllipsis(ellipsis) {
|
|
83
|
+
if (ellipsis === undefined) return '…';
|
|
84
|
+
if (typeof ellipsis !== 'string' || /[\x00-\x1f\x7f-\x9f]/u.test(ellipsis)) {
|
|
85
|
+
throw new TypeError('ellipsis must be a single-line string without terminal control characters');
|
|
86
|
+
}
|
|
87
|
+
return ellipsis;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Truncate a cell to a display width, ending with an ellipsis. A custom
|
|
91
|
+
* `ellipsis` (including '' for a hard cut) replaces the default '…'; one
|
|
92
|
+
* wider than the column falls back to '…'. */
|
|
93
|
+
function truncateDisplay(cell, width, ellipsis = '…') {
|
|
94
|
+
if (displayWidth(cell) <= width) return cell;
|
|
95
|
+
const mark = displayWidth(ellipsis) > width ? '…' : ellipsis;
|
|
96
|
+
const markWidth = displayWidth(mark);
|
|
97
|
+
if (width <= markWidth) return mark;
|
|
98
|
+
let out = '';
|
|
99
|
+
let used = 0;
|
|
100
|
+
for (const char of cell) {
|
|
101
|
+
const charWidth = displayWidth(char);
|
|
102
|
+
if (used + charWidth > width - markWidth) break;
|
|
103
|
+
out += char;
|
|
104
|
+
used += charWidth;
|
|
105
|
+
}
|
|
106
|
+
return `${out}${mark}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeMax(max) {
|
|
110
|
+
if (max === undefined) return [];
|
|
111
|
+
if (!Array.isArray(max)) {
|
|
112
|
+
throw new TypeError('max must be an array of per-column width caps');
|
|
113
|
+
}
|
|
114
|
+
return max.map((value) => {
|
|
115
|
+
if (value === null || value === undefined) return undefined;
|
|
116
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
|
|
117
|
+
throw new TypeError('max entries must be positive integers, null, or undefined');
|
|
118
|
+
}
|
|
119
|
+
return value;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Measure column display widths across one or more grids. Pair with
|
|
124
|
+
* renderGrid to align several tables on one shared grid.
|
|
125
|
+
* Each column's width is simply the widest cell (headers included) — no
|
|
126
|
+
* statistical trimming. When real data shows a column needs a bound, pass
|
|
127
|
+
* { max: [cap, ...] } with a manually chosen cap per column (null/undefined
|
|
128
|
+
* entries stay uncapped); over-width non-trailing cells then truncate with
|
|
129
|
+
* an ellipsis at render time. A cap on the trailing column has no visible
|
|
130
|
+
* effect by default: pass truncateTrailing at render time to bound it too. */
|
|
131
|
+
function measureColumns(grids, { max } = {}) {
|
|
132
|
+
if (!Array.isArray(grids)) {
|
|
133
|
+
throw new TypeError('grids must be an array of grids');
|
|
134
|
+
}
|
|
135
|
+
const caps = normalizeMax(max);
|
|
136
|
+
const widths = [];
|
|
137
|
+
const push = (column, width) => {
|
|
138
|
+
widths[column] = Math.max(widths[column] ?? 0, width);
|
|
139
|
+
};
|
|
140
|
+
for (const input of grids) {
|
|
141
|
+
const grid = normalizeGrid(input);
|
|
142
|
+
if (grid.headers !== undefined) {
|
|
143
|
+
normalizeRow(grid.headers).forEach((cell, column) => {
|
|
144
|
+
push(column, displayWidth(cell));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
for (const row of grid.rows.map(normalizeRow)) {
|
|
148
|
+
row.forEach((cell, column) => {
|
|
149
|
+
push(column, displayWidth(cell));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return widths.map((width, column) => {
|
|
154
|
+
const cap = caps[column];
|
|
155
|
+
return cap === undefined ? width : Math.min(width, cap);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
exports.measureColumns = measureColumns;
|
|
160
|
+
|
|
161
|
+
function normalizeMargin(margin) {
|
|
162
|
+
if (margin === undefined) return '';
|
|
163
|
+
if (typeof margin === 'number' && Number.isInteger(margin) && margin >= 0) return ' '.repeat(margin);
|
|
164
|
+
if (typeof margin === 'string' && !/[\x00-\x1f\x7f-\x9f]/u.test(margin)) return margin;
|
|
165
|
+
throw new TypeError('margin must be a number of spaces or a single-line string');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Render rows with explicit column widths (from measureColumns). Non-trailing
|
|
169
|
+
* cells wider than their column are truncated with an ellipsis so the grid
|
|
170
|
+
* stays aligned (pass { truncate: false } to let them overflow); trailing
|
|
171
|
+
* cells render in full by default. Set truncateTrailing to also truncate the
|
|
172
|
+
* last populated cell of each row; left-aligned trailing cells stay unpadded.
|
|
173
|
+
* Omitted widths measure the given rows alone (equivalent to
|
|
174
|
+
* formatTable); { max } caps that self-measurement per column (it is an
|
|
175
|
+
* error to pass both widths and max). `margin` (spaces count or string)
|
|
176
|
+
* indents every line; `ellipsis` (default '…') is the truncation mark —
|
|
177
|
+
* '' gives a hard cut. */
|
|
178
|
+
function renderGrid(rows, { widths, headers, align, truncate = true, truncateTrailing = false, margin, max, ellipsis } = {}) {
|
|
179
|
+
if (!Array.isArray(rows)) {
|
|
180
|
+
throw new TypeError('rows must be an array of row arrays');
|
|
181
|
+
}
|
|
182
|
+
if (widths !== undefined && !Array.isArray(widths)) {
|
|
183
|
+
throw new TypeError('widths must be an array of column widths');
|
|
184
|
+
}
|
|
185
|
+
if (widths !== undefined && max !== undefined) {
|
|
186
|
+
throw new TypeError('pass widths or max, not both (max caps a measurement)');
|
|
187
|
+
}
|
|
188
|
+
const indent = normalizeMargin(margin);
|
|
189
|
+
const alignment = normalizeAlign(align);
|
|
190
|
+
const mark = normalizeEllipsis(ellipsis);
|
|
191
|
+
const columns = widths ?? measureColumns([{ rows, headers }], { max });
|
|
192
|
+
const table = rows.map(normalizeRow);
|
|
193
|
+
if (headers !== undefined) table.unshift(normalizeRow(headers));
|
|
194
|
+
|
|
195
|
+
const body = table.map((row) => {
|
|
196
|
+
// Omit absent trailing cells, but preserve padding before later populated cells.
|
|
197
|
+
let last = row.length - 1;
|
|
198
|
+
while (last >= 0 && row[last] === '') last--;
|
|
199
|
+
return row.slice(0, last + 1).map((cell, column) => {
|
|
200
|
+
const width = columns[column] || 0;
|
|
201
|
+
const fitted = truncate && width > 0 && (column !== last || truncateTrailing)
|
|
202
|
+
? truncateDisplay(cell, width, mark) : cell;
|
|
203
|
+
if (alignment[column] === 'right') return padDisplay(fitted, width, true);
|
|
204
|
+
return column === last ? fitted : padDisplay(fitted, width, false);
|
|
205
|
+
}).join(' ');
|
|
206
|
+
}).join('\n');
|
|
207
|
+
if (body === '' || indent === '') return body;
|
|
208
|
+
return `${indent}${body.split('\n').join(`\n${indent}`)}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
exports.renderGrid = renderGrid;
|
|
212
|
+
|
|
213
|
+
/** Format rows using the widest cell in each column, with two spaces between columns. */
|
|
214
|
+
function formatTable(rows, { headers, align, truncate, truncateTrailing, margin, max, ellipsis } = {}) {
|
|
215
|
+
return renderGrid(rows, { headers, align, truncate, truncateTrailing, margin, max, ellipsis });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Print a formatted table and a final newline; empty output writes nothing. */
|
|
219
|
+
function printTable(rows, { headers, align, truncate, truncateTrailing, margin, max, ellipsis, stream = process.stdout } = {}) {
|
|
220
|
+
const output = formatTable(rows, { headers, align, truncate, truncateTrailing, margin, max, ellipsis });
|
|
221
|
+
if (output) stream.write(`${output}\n`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
exports.formatTable = formatTable;
|
|
225
|
+
exports.printTable = printTable;
|
|
226
|
+
|
|
227
|
+
function timestamp(value) {
|
|
228
|
+
if (value instanceof Date) return value.getTime();
|
|
229
|
+
if (typeof value === 'number') return value;
|
|
230
|
+
if (typeof value === 'string' && value.trim()) return Date.parse(value);
|
|
231
|
+
return NaN;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Compact time until a timestamp; past times carry an "ago" suffix. */
|
|
235
|
+
function formatRelativeTime(value, { now = Date.now() } = {}) {
|
|
236
|
+
const reference = timestamp(now);
|
|
237
|
+
if (!Number.isFinite(reference)) throw new TypeError('now must be a valid timestamp');
|
|
238
|
+
const target = timestamp(value);
|
|
239
|
+
if (!Number.isFinite(target)) return 'unknown';
|
|
240
|
+
|
|
241
|
+
const difference = target - reference;
|
|
242
|
+
const seconds = Math.floor(Math.abs(difference) / 1000);
|
|
243
|
+
if (seconds === 0) return 'now';
|
|
244
|
+
for (const [unit, size] of [['w', 604800], ['d', 86400], ['h', 3600], ['m', 60], ['s', 1]]) {
|
|
245
|
+
if (seconds >= size) {
|
|
246
|
+
return `${Math.floor(seconds / size)}${unit}${difference < 0 ? ' ago' : ''}`;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
exports.formatRelativeTime = formatRelativeTime;
|
|
252
|
+
|
|
253
|
+
/** Compact token/byte-style count: 960462 -> "960K", 15791104 -> "15.8M". */
|
|
254
|
+
function formatCount(value) {
|
|
255
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return 'unknown';
|
|
256
|
+
const sign = value < 0 ? '-' : '';
|
|
257
|
+
const absolute = Math.abs(value);
|
|
258
|
+
if (absolute < 1_000) return String(value);
|
|
259
|
+
const thousands = Math.round(absolute / 1_000);
|
|
260
|
+
// Promote after rounding so 999_999 becomes "1.0M", not "1000K".
|
|
261
|
+
if (thousands < 1_000) return `${sign}${thousands}K`;
|
|
262
|
+
return `${sign}${(absolute / 1_000_000).toFixed(1)}M`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
exports.formatCount = formatCount;
|
|
266
|
+
|
|
267
|
+
const DURATION_UNITS = [['w', 604800], ['d', 86400], ['h', 3600], ['m', 60], ['s', 1]];
|
|
268
|
+
|
|
269
|
+
/** Elapsed duration. Default style is up to two spaced units: "45s",
|
|
270
|
+
* "3m 12s", "2h 5m", "1w 2d". `style: 'compact'` renders zero-padded
|
|
271
|
+
* adjacent units down to MINUTES with no seconds: "2d02h03m", "04h59m",
|
|
272
|
+
* "<1m" — dense cells for grids. `maxUnit` ('w' default, or 'd'/'h'/'m'/'s')
|
|
273
|
+
* caps the largest unit — e.g. 'd' renders 9 days as "9d 3h" / "9d03h"
|
|
274
|
+
* instead of "1w 2d". */
|
|
275
|
+
function formatDuration(milliseconds, { maxUnit = 'w', style = 'spaced' } = {}) {
|
|
276
|
+
if (typeof milliseconds !== 'number' || !Number.isFinite(milliseconds)) return 'unknown';
|
|
277
|
+
const start = DURATION_UNITS.findIndex(([unit]) => unit === maxUnit);
|
|
278
|
+
if (start === -1) throw new TypeError('maxUnit must be one of "w", "d", "h", "m", "s"');
|
|
279
|
+
if (style !== 'spaced' && style !== 'compact') throw new TypeError('style must be "spaced" or "compact"');
|
|
280
|
+
const secondsTotal = Math.floor(Math.max(0, milliseconds) / 1000);
|
|
281
|
+
if (style === 'compact') {
|
|
282
|
+
const units = DURATION_UNITS.slice(start).filter(([unit]) => unit !== 's');
|
|
283
|
+
if (units.length === 0 || secondsTotal < 60) return '<1m';
|
|
284
|
+
const parts = [];
|
|
285
|
+
let rest = secondsTotal;
|
|
286
|
+
let started = false;
|
|
287
|
+
for (const [unit, size] of units) {
|
|
288
|
+
const amount = Math.floor(rest / size);
|
|
289
|
+
rest -= amount * size;
|
|
290
|
+
if (amount > 0 && !started) {
|
|
291
|
+
const text = unit === 'd' || unit === 'w' ? `${amount}${unit}` : `${pad2(amount)}${unit}`;
|
|
292
|
+
parts.push({ text, zero: false });
|
|
293
|
+
started = true;
|
|
294
|
+
} else if (started) {
|
|
295
|
+
parts.push({ text: `${pad2(amount)}${unit}`, zero: amount === 0 });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Positional zeros stay mid-sequence ("2d00h03m"); trailing zeros drop.
|
|
299
|
+
while (parts.length > 0 && parts[parts.length - 1].zero) parts.pop();
|
|
300
|
+
return parts.map((part) => part.text).join('');
|
|
301
|
+
}
|
|
302
|
+
let seconds = secondsTotal;
|
|
303
|
+
const parts = [];
|
|
304
|
+
for (const [unit, size] of DURATION_UNITS.slice(start)) {
|
|
305
|
+
if (seconds >= size || (unit === 's' && parts.length === 0)) {
|
|
306
|
+
const amount = Math.floor(seconds / size);
|
|
307
|
+
seconds -= amount * size;
|
|
308
|
+
parts.push(`${amount}${unit}`);
|
|
309
|
+
if (parts.length === 2) break;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return parts.join(' ');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
exports.formatDuration = formatDuration;
|
|
316
|
+
|
|
317
|
+
function localDate(value) {
|
|
318
|
+
if (value === undefined) return new Date();
|
|
319
|
+
if (value instanceof Date) return value;
|
|
320
|
+
if (typeof value === 'number' || typeof value === 'string') return new Date(value);
|
|
321
|
+
return new Date(NaN);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function pad2(value) {
|
|
325
|
+
return String(value).padStart(2, '0');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Local wall clock "HH:MM:SS", for step narration on stderr. */
|
|
329
|
+
function formatClock(value) {
|
|
330
|
+
const date = localDate(value);
|
|
331
|
+
if (Number.isNaN(date.getTime())) return 'unknown';
|
|
332
|
+
return `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
exports.formatClock = formatClock;
|
|
336
|
+
|
|
337
|
+
/** Local timestamp "YYYY-MM-DD HH:MM:SS", for log-style lines. */
|
|
338
|
+
function formatLocalTimestamp(value) {
|
|
339
|
+
const date = localDate(value);
|
|
340
|
+
if (Number.isNaN(date.getTime())) return 'unknown';
|
|
341
|
+
const day = `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
342
|
+
return `${day} ${formatClock(date)}`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
exports.formatLocalTimestamp = formatLocalTimestamp;
|
|
346
|
+
|
|
347
|
+
/** Percentage from a fraction: 0.16 -> "16%"; non-finite input -> "?%". */
|
|
348
|
+
function formatPercent(fraction) {
|
|
349
|
+
return typeof fraction === 'number' && Number.isFinite(fraction)
|
|
350
|
+
? `${Math.round(fraction * 100)}%`
|
|
351
|
+
: '?%';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
exports.formatPercent = formatPercent;
|
|
355
|
+
|
|
356
|
+
/** Progress bar with customizable single-column glyphs; defaults to ASCII.
|
|
357
|
+
* Non-finite input repeats the unknown glyph (default "?"). */
|
|
358
|
+
function formatBar(fraction, { width = 10, fill = '#', empty = ' ', unknown = '?' } = {}) {
|
|
359
|
+
if (typeof width !== 'number' || !Number.isInteger(width) || width < 1) {
|
|
360
|
+
throw new TypeError('width must be a positive integer');
|
|
361
|
+
}
|
|
362
|
+
for (const [name, glyph] of [['fill', fill], ['empty', empty], ['unknown', unknown]]) {
|
|
363
|
+
if (typeof glyph !== 'string' || [...glyph].length !== 1 ||
|
|
364
|
+
/[\x00-\x1f\x7f-\x9f]/u.test(glyph) || displayWidth(glyph) !== 1) {
|
|
365
|
+
throw new TypeError(`${name} must be a single character with terminal display width 1`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (typeof fraction !== 'number' || !Number.isFinite(fraction)) {
|
|
369
|
+
return unknown.repeat(width);
|
|
370
|
+
}
|
|
371
|
+
const filled = Math.min(width, Math.max(0, Math.round(fraction * width)));
|
|
372
|
+
return `${fill.repeat(filled)}${empty.repeat(width - filled)}`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
exports.formatBar = formatBar;
|
|
376
|
+
|
|
377
|
+
/** Clock-style remaining time: "00:36:12", days-prefixed past 24h
|
|
378
|
+
* ("5d 18:24:33"). Floors to whole seconds. */
|
|
379
|
+
function formatTimer(milliseconds) {
|
|
380
|
+
if (typeof milliseconds !== 'number' || !Number.isFinite(milliseconds)) return 'unknown';
|
|
381
|
+
let seconds = Math.floor(Math.max(0, milliseconds) / 1000);
|
|
382
|
+
const days = Math.floor(seconds / 86400);
|
|
383
|
+
seconds -= days * 86400;
|
|
384
|
+
const hours = Math.floor(seconds / 3600);
|
|
385
|
+
seconds -= hours * 3600;
|
|
386
|
+
const minutes = Math.floor(seconds / 60);
|
|
387
|
+
seconds -= minutes * 60;
|
|
388
|
+
const clock = `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)}`;
|
|
389
|
+
return days > 0 ? `${days}d ${clock}` : clock;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
exports.formatTimer = formatTimer;
|
|
393
|
+
|
|
394
|
+
/** Escape terminal control characters so untrusted text survives formatTable. */
|
|
395
|
+
function escapeCell(value) {
|
|
396
|
+
const text = value == null ? '' : String(value);
|
|
397
|
+
return text.replace(/[\x00-\x1f\x7f-\x9f]/gu, (char) => {
|
|
398
|
+
if (char === '\n') return '\\n';
|
|
399
|
+
if (char === '\t') return '\\t';
|
|
400
|
+
if (char === '\r') return '\\r';
|
|
401
|
+
return `\\x${char.codePointAt(0).toString(16).padStart(2, '0')}`;
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
exports.escapeCell = escapeCell;
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lmctl-ai/lmformat",
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"description": "Dependency-free CLI output formatting: auto-sized tables, compact counts, durations, and timestamps",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Mike Ma <mike.ma@lmctl.com>",
|
|
9
|
+
"homepage": "https://lmctl.com",
|
|
10
|
+
"repository": { "type": "git", "url": "git+https://github.com/lmctl-ai/lmformat.git" },
|
|
11
|
+
"files": ["index.js", "index.d.ts"],
|
|
12
|
+
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "npm run check && node scripts/build.js",
|
|
15
|
+
"test": "node --test",
|
|
16
|
+
"check": "node --check index.js && node --check test/table.test.js && node --check test/time.test.js && node --check test/format.test.js && node --check examples/sessions.js && node --check examples/ratelimit.js",
|
|
17
|
+
"example": "node examples/sessions.js",
|
|
18
|
+
"example:ratelimit": "node examples/ratelimit.js"
|
|
19
|
+
},
|
|
20
|
+
"engines": { "node": ">=18" }
|
|
21
|
+
}
|