clap-ts 0.2.0 → 0.3.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.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Markdown documentation, shaped like the clap-markdown crate's output: one
3
+ * heading per command, its usage line, then Commands, Arguments and Options
4
+ * lists, with nested commands appended as deeper headings.
5
+ *
6
+ * Suitable for committing next to a README or feeding a docs site.
7
+ */
8
+ import type { CommandDef, MarkdownOptions } from './types.js';
9
+ export type { MarkdownOptions } from './types.js';
10
+ /**
11
+ * Render the whole command tree as one markdown document.
12
+ *
13
+ * ```ts
14
+ * writeFileSync('docs/cli.md', renderMarkdownHelp(command));
15
+ * ```
16
+ */
17
+ export declare function renderMarkdownHelp(command: CommandDef, opts?: MarkdownOptions): string;
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Markdown documentation, shaped like the clap-markdown crate's output: one
3
+ * heading per command, its usage line, then Commands, Arguments and Options
4
+ * lists, with nested commands appended as deeper headings.
5
+ *
6
+ * Suitable for committing next to a README or feeding a docs site.
7
+ */
8
+ import { hasSubCommands, possibleValues, subCommandsOf } from './parser.js';
9
+ /** Escape the markdown that could break a list item or table cell. */
10
+ function esc(text) {
11
+ return text.replaceAll(/([\\`*_[\]<>|])/g, '\\$1');
12
+ }
13
+ function isVisible(def) {
14
+ return !def.hidden && !def.hideLongHelp;
15
+ }
16
+ function valuePlaceholder(key, def) {
17
+ if (def.valueNames && def.valueNames.length > 0) {
18
+ return def.valueNames.map((n) => `<${n}>`).join(' ');
19
+ }
20
+ return `<${def.valueName ?? (def.long ?? key).toUpperCase()}>`;
21
+ }
22
+ function usageLine(path, command) {
23
+ const argsDef = command.args ?? {};
24
+ const parts = [...path];
25
+ if (Object.values(argsDef).some((def) => def.type !== 'positional' && isVisible(def))) {
26
+ parts.push('[OPTIONS]');
27
+ }
28
+ for (const [key, def] of Object.entries(argsDef)) {
29
+ if (def.type !== 'positional' || !isVisible(def)) {
30
+ continue;
31
+ }
32
+ const name = `<${def.valueName ?? key.toUpperCase()}>`;
33
+ parts.push(def.required ? name : `[${name}]`);
34
+ }
35
+ if (hasSubCommands(command)) {
36
+ parts.push(`[${command.meta.subcommandValueName ?? 'COMMAND'}]`);
37
+ }
38
+ return parts.join(' ');
39
+ }
40
+ /** Trailing notes for one argument: default, env, possible values, required. */
41
+ function notes(def) {
42
+ const out = [];
43
+ const values = def.hidePossibleValues ? [] : possibleValues(def).filter((v) => !v.hidden);
44
+ if (values.length > 0) {
45
+ if (values.some((v) => v.help)) {
46
+ out.push('Possible values:');
47
+ for (const value of values) {
48
+ out.push(` - \`${value.name}\`${value.help ? `: ${esc(value.help)}` : ''}`);
49
+ }
50
+ }
51
+ else {
52
+ out.push(`Possible values: ${values.map((v) => `\`${v.name}\``).join(', ')}`);
53
+ }
54
+ }
55
+ if (def.default !== undefined && !def.hideDefaultValue) {
56
+ const shown = Array.isArray(def.default) ? def.default.join(', ') : String(def.default);
57
+ out.push(`Default value: \`${shown}\``);
58
+ }
59
+ if (def.env && !def.hideEnv) {
60
+ out.push(`Environment: \`${def.env}\``);
61
+ }
62
+ if (def.required) {
63
+ out.push('Required.');
64
+ }
65
+ return out;
66
+ }
67
+ function renderArgList(entries, label, lines) {
68
+ for (const [key, def] of entries) {
69
+ const description = def.longDescription ?? def.description ?? '';
70
+ lines.push(`* ${label(key, def)}${description ? ` - ${esc(description)}` : ''}`);
71
+ for (const note of notes(def)) {
72
+ lines.push(` ${note}`);
73
+ }
74
+ }
75
+ lines.push('');
76
+ }
77
+ function renderCommand(command, path, depth, lines) {
78
+ const { meta } = command;
79
+ const argsDef = command.args ?? {};
80
+ const heading = '#'.repeat(Math.min(depth, 6));
81
+ lines.push(`${heading} \`${path.join(' ')}\``);
82
+ lines.push('');
83
+ const about = meta.longAbout ?? meta.about ?? meta.description;
84
+ if (about) {
85
+ lines.push(esc(about));
86
+ lines.push('');
87
+ }
88
+ lines.push(`**Usage:** \`${usageLine(path, command)}\``);
89
+ lines.push('');
90
+ const subs = Object.entries(subCommandsOf(command)).filter(([, def]) => !def.meta.hidden);
91
+ if (subs.length > 0) {
92
+ lines.push(`${heading}# Commands`);
93
+ lines.push('');
94
+ for (const [name, def] of subs) {
95
+ const aliases = def.meta.aliases ?? [];
96
+ const alias = aliases.length > 0 ? ` (${aliases.map((a) => `\`${a}\``).join(', ')})` : '';
97
+ const description = def.meta.description ?? def.meta.about ?? '';
98
+ lines.push(`* \`${name}\`${alias}${description ? ` - ${esc(description)}` : ''}`);
99
+ }
100
+ lines.push('');
101
+ }
102
+ const positionals = Object.entries(argsDef).filter(([, def]) => def.type === 'positional' && isVisible(def));
103
+ if (positionals.length > 0) {
104
+ lines.push(`${heading}# Arguments`);
105
+ lines.push('');
106
+ renderArgList(positionals, (key, def) => `\`<${def.valueName ?? key.toUpperCase()}>\``, lines);
107
+ }
108
+ const options = Object.entries(argsDef).filter(([, def]) => def.type !== 'positional' && isVisible(def));
109
+ const hasHelp = meta.disableHelpFlag !== true;
110
+ const hasVersion = meta.version !== undefined && meta.disableVersionFlag !== true;
111
+ if (options.length > 0 || hasHelp || hasVersion) {
112
+ lines.push(`${heading}# Options`);
113
+ lines.push('');
114
+ renderArgList(options, (key, def) => {
115
+ // The long form and its placeholder share one code span, as
116
+ // clap-markdown renders them: `--config <CONFIG>`.
117
+ const value = def.type !== 'boolean' && def.action !== 'count'
118
+ ? ` ${valuePlaceholder(key, def)}`
119
+ : '';
120
+ const long = `\`--${def.long ?? key}${value}\``;
121
+ return def.short ? `\`-${def.short}\`, ${long}` : long;
122
+ }, lines);
123
+ const builtins = [];
124
+ if (hasHelp) {
125
+ builtins.push('* `-h`, `--help` - Print help');
126
+ }
127
+ if (hasVersion) {
128
+ builtins.push('* `-V`, `--version` - Print version');
129
+ }
130
+ if (builtins.length > 0) {
131
+ // The list above already ended with a blank line, so reopen it.
132
+ lines.splice(lines.length - 1, 0, ...builtins);
133
+ }
134
+ }
135
+ const after = meta.afterLongHelp ?? meta.afterHelp;
136
+ if (after) {
137
+ lines.push(esc(after));
138
+ lines.push('');
139
+ }
140
+ for (const [name, sub] of subs) {
141
+ renderCommand(sub, [...path, name], depth + 1, lines);
142
+ }
143
+ }
144
+ /**
145
+ * Render the whole command tree as one markdown document.
146
+ *
147
+ * ```ts
148
+ * writeFileSync('docs/cli.md', renderMarkdownHelp(command));
149
+ * ```
150
+ */
151
+ export function renderMarkdownHelp(command, opts) {
152
+ const name = opts?.name ?? command.meta.binName ?? command.meta.name;
153
+ const lines = [];
154
+ if (opts?.title !== undefined) {
155
+ lines.push(`# ${opts.title}`);
156
+ lines.push('');
157
+ }
158
+ renderCommand(command, [name], opts?.title === undefined ? 1 : 2, lines);
159
+ if (opts?.footer !== undefined) {
160
+ lines.push(opts.footer);
161
+ lines.push('');
162
+ }
163
+ // Collapse the runs of blank lines the section joins leave behind.
164
+ return `${lines.join('\n').replaceAll(/\n{3,}/g, '\n\n').trimEnd()}\n`;
165
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Structured terminal output: tables, key-value blocks and trees.
3
+ *
4
+ * ```ts
5
+ * import { table, keyValue, tree } from 'clap-ts/output';
6
+ *
7
+ * ctx.stdout.write(table(rows, { columns: [{ key: 'name' }, { key: 'size', align: 'right' }] }));
8
+ * ```
9
+ *
10
+ * Everything returns a string rather than writing, so the same call works
11
+ * against `ctx.stdout`, a file, or an assertion. Widths follow the terminal,
12
+ * and colour is only emitted when the destination can show it.
13
+ */
14
+ /** Columns a single code point occupies: 0, 1 or 2. */
15
+ export declare function codePointWidth(cp: number): number;
16
+ /**
17
+ * Visible width of a string: ANSI escapes skipped, wide characters counted as
18
+ * two columns, combining marks as none.
19
+ *
20
+ * A single pass with no allocation. Measuring by `String.length` lines a table
21
+ * up wrongly the moment a cell holds CJK or an emoji, and stripping escapes
22
+ * with a replace allocates a string per cell.
23
+ */
24
+ export declare function displayWidth(text: string): number;
25
+ /**
26
+ * Truncate to a visible width, ending in an ellipsis when it does not fit.
27
+ *
28
+ * Walks code points rather than code units, so a surrogate pair is never split
29
+ * into a lone half, and a wide character is never counted as one column.
30
+ */
31
+ export declare function truncate(text: string, width: number): string;
32
+ export type Align = 'left' | 'right' | 'center';
33
+ export interface Column<T> {
34
+ /** Property to read from each row. */
35
+ readonly key: keyof T & string;
36
+ /** Header text; defaults to the key. */
37
+ readonly header?: string;
38
+ /** Column alignment (default left). */
39
+ readonly align?: Align;
40
+ /** Cap this column's width, truncating what does not fit. */
41
+ readonly maxWidth?: number;
42
+ /** Render a cell; defaults to String(value), with undefined and null as ''. */
43
+ readonly render?: (value: T[keyof T & string], row: T) => string;
44
+ }
45
+ export interface TableOptions<T> {
46
+ readonly columns: readonly Column<T>[];
47
+ /** Show the header row (default true). */
48
+ readonly header?: boolean;
49
+ /** Gap between columns (default 2 spaces). */
50
+ readonly gap?: number;
51
+ /** Total width to fit; defaults to the terminal width. */
52
+ readonly width?: number;
53
+ /** Draw a rule under the header (default false). */
54
+ readonly rule?: boolean;
55
+ /** Left indent for every line. */
56
+ readonly indent?: number;
57
+ /** Style the header; defaults to bold when colour is on. */
58
+ readonly headerStyle?: (text: string) => string;
59
+ }
60
+ /** Whether the destination can show colour, matching how help decides. */
61
+ export declare function colorEnabled(): boolean;
62
+ /**
63
+ * Render rows as aligned columns.
64
+ *
65
+ * Columns are sized to their widest cell, then shrunk from the widest down
66
+ * until the whole table fits the available width. Nothing is truncated while
67
+ * anything still fits, so a narrow terminal costs the sprawling column first
68
+ * rather than every column equally.
69
+ */
70
+ export declare function table<T extends Record<string, unknown>>(rows: readonly T[], opts: TableOptions<T>): string;
71
+ export interface KeyValueOptions {
72
+ /** Left indent for every line. */
73
+ readonly indent?: number;
74
+ /** Separator between key and value (default ': '). */
75
+ readonly separator?: string;
76
+ /** Total width to wrap within; defaults to the terminal width. */
77
+ readonly width?: number;
78
+ /** Style the keys; defaults to bold when colour is on. */
79
+ readonly keyStyle?: (text: string) => string;
80
+ }
81
+ /**
82
+ * Render pairs as an aligned block, wrapping long values under their key.
83
+ *
84
+ * ```
85
+ * Name: clap-ts
86
+ * Version: 0.3.0
87
+ * ```
88
+ */
89
+ export declare function keyValue(pairs: readonly (readonly [string, string])[] | Record<string, string>, opts?: KeyValueOptions): string;
90
+ /** A node in a tree, with whatever children it has. */
91
+ export interface TreeNode {
92
+ readonly label: string;
93
+ readonly children?: readonly TreeNode[];
94
+ }
95
+ export interface TreeOptions {
96
+ /** Use ASCII connectors instead of box-drawing characters. */
97
+ readonly ascii?: boolean;
98
+ /** Left indent for every line. */
99
+ readonly indent?: number;
100
+ }
101
+ /**
102
+ * Render a tree with connector lines.
103
+ *
104
+ * ```
105
+ * root
106
+ * ├── one
107
+ * │ └── nested
108
+ * └── two
109
+ * ```
110
+ */
111
+ export declare function tree(root: TreeNode | readonly TreeNode[], opts?: TreeOptions): string;
package/dist/output.js ADDED
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Structured terminal output: tables, key-value blocks and trees.
3
+ *
4
+ * ```ts
5
+ * import { table, keyValue, tree } from 'clap-ts/output';
6
+ *
7
+ * ctx.stdout.write(table(rows, { columns: [{ key: 'name' }, { key: 'size', align: 'right' }] }));
8
+ * ```
9
+ *
10
+ * Everything returns a string rather than writing, so the same call works
11
+ * against `ctx.stdout`, a file, or an assertion. Widths follow the terminal,
12
+ * and colour is only emitted when the destination can show it.
13
+ */
14
+ import { styleText } from 'node:util';
15
+ /**
16
+ * Character width tables.
17
+ *
18
+ * Flat sorted [start, end, ...] pairs searched by bisection: a handful of
19
+ * comparisons per non-ASCII code point, and no allocation. Covers the East
20
+ * Asian Wide and Fullwidth ranges plus emoji, which is what actually breaks
21
+ * column alignment in a terminal.
22
+ */
23
+ const WIDE = Int32Array.from([
24
+ 0x1100, 0x115f, 0x2329, 0x232a, 0x2e80, 0x303e, 0x3041, 0x33ff,
25
+ 0x3400, 0x4dbf, 0x4e00, 0x9fff, 0xa000, 0xa4cf, 0xa960, 0xa97f,
26
+ 0xac00, 0xd7a3, 0xf900, 0xfaff, 0xfe10, 0xfe19, 0xfe30, 0xfe6f,
27
+ 0xff00, 0xff60, 0xffe0, 0xffe6,
28
+ 0x1f004, 0x1f004, 0x1f0cf, 0x1f0cf, 0x1f18e, 0x1f18e, 0x1f191, 0x1f19a,
29
+ 0x1f200, 0x1f320, 0x1f32d, 0x1f335, 0x1f337, 0x1f37c, 0x1f37e, 0x1f393,
30
+ 0x1f3a0, 0x1f3ca, 0x1f3cf, 0x1f3d3, 0x1f3e0, 0x1f3f0, 0x1f3f4, 0x1f3f4,
31
+ 0x1f3f8, 0x1f43e, 0x1f440, 0x1f440, 0x1f442, 0x1f4fc, 0x1f4ff, 0x1f53d,
32
+ 0x1f54b, 0x1f54e, 0x1f550, 0x1f567, 0x1f57a, 0x1f57a, 0x1f595, 0x1f596,
33
+ 0x1f5a4, 0x1f5a4, 0x1f5fb, 0x1f64f, 0x1f680, 0x1f6c5, 0x1f6cc, 0x1f6cc,
34
+ 0x1f6d0, 0x1f6d2, 0x1f6eb, 0x1f6ec, 0x1f6f4, 0x1f6fc, 0x1f7e0, 0x1f7eb,
35
+ 0x1f90c, 0x1f93a, 0x1f93c, 0x1f945, 0x1f947, 0x1f9ff, 0x1fa70, 0x1faff,
36
+ 0x20000, 0x3fffd,
37
+ ]);
38
+ /** Marks, joiners and selectors that occupy no column of their own. */
39
+ const ZERO = Int32Array.from([
40
+ 0x0300, 0x036f, 0x0483, 0x0489, 0x0591, 0x05bd, 0x0610, 0x061a,
41
+ 0x064b, 0x065f, 0x0670, 0x0670, 0x06d6, 0x06dc, 0x0730, 0x074a,
42
+ 0x07eb, 0x07f3, 0x0816, 0x0819, 0x081b, 0x0823, 0x0825, 0x0827,
43
+ 0x0829, 0x082d, 0x0859, 0x085b, 0x08e3, 0x0903, 0x093a, 0x093c,
44
+ 0x0941, 0x0948, 0x094d, 0x094d, 0x0951, 0x0957, 0x1ab0, 0x1aff,
45
+ 0x1dc0, 0x1dff, 0x200b, 0x200f, 0x2028, 0x202e, 0x2060, 0x2064,
46
+ 0x20d0, 0x20f0, 0xfe00, 0xfe0f, 0xfe20, 0xfe2f, 0xfeff, 0xfeff,
47
+ 0x1f3fb, 0x1f3ff, 0xe0100, 0xe01ef,
48
+ ]);
49
+ /** Whether a code point falls inside a flat sorted range table. */
50
+ function inRanges(table, cp) {
51
+ let low = 0;
52
+ let high = table.length / 2 - 1;
53
+ while (low <= high) {
54
+ const mid = (low + high) >> 1;
55
+ if (cp < table[mid * 2]) {
56
+ high = mid - 1;
57
+ }
58
+ else if (cp > table[mid * 2 + 1]) {
59
+ low = mid + 1;
60
+ }
61
+ else {
62
+ return true;
63
+ }
64
+ }
65
+ return false;
66
+ }
67
+ /** Columns a single code point occupies: 0, 1 or 2. */
68
+ export function codePointWidth(cp) {
69
+ if (cp < 0x7f) {
70
+ // C0 controls take no space; everything else printable takes one.
71
+ return cp < 0x20 ? 0 : 1;
72
+ }
73
+ if (cp < 0xa0) {
74
+ return 0;
75
+ }
76
+ if (inRanges(ZERO, cp)) {
77
+ return 0;
78
+ }
79
+ return inRanges(WIDE, cp) ? 2 : 1;
80
+ }
81
+ const ESC = 0x1b;
82
+ /**
83
+ * Visible width of a string: ANSI escapes skipped, wide characters counted as
84
+ * two columns, combining marks as none.
85
+ *
86
+ * A single pass with no allocation. Measuring by `String.length` lines a table
87
+ * up wrongly the moment a cell holds CJK or an emoji, and stripping escapes
88
+ * with a replace allocates a string per cell.
89
+ */
90
+ export function displayWidth(text) {
91
+ let width = 0;
92
+ for (let i = 0; i < text.length; i++) {
93
+ const code = text.charCodeAt(i);
94
+ if (code === ESC) {
95
+ i = skipEscape(text, i);
96
+ continue;
97
+ }
98
+ if (code < 0x7f) {
99
+ if (code >= 0x20) {
100
+ width++;
101
+ }
102
+ continue;
103
+ }
104
+ // Combine a surrogate pair into one code point before measuring.
105
+ let cp = code;
106
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
107
+ const low = text.charCodeAt(i + 1);
108
+ if (low >= 0xdc00 && low <= 0xdfff) {
109
+ cp = (code - 0xd800) * 0x400 + low - 0xdc00 + 0x10000;
110
+ i++;
111
+ }
112
+ }
113
+ width += codePointWidth(cp);
114
+ }
115
+ return width;
116
+ }
117
+ /** Index of the last character of a CSI sequence starting at `start`. */
118
+ function skipEscape(text, start) {
119
+ if (text.charCodeAt(start + 1) !== 0x5b) {
120
+ return start;
121
+ }
122
+ let i = start + 2;
123
+ while (i < text.length) {
124
+ const code = text.charCodeAt(i);
125
+ // Parameter and intermediate bytes run until a final byte in @ to ~.
126
+ if (code >= 0x40 && code <= 0x7e) {
127
+ return i;
128
+ }
129
+ i++;
130
+ }
131
+ return text.length;
132
+ }
133
+ const ANSI = /\x1b\[[0-9;]*m/g;
134
+ function stripAnsi(text) {
135
+ return text.replace(ANSI, '');
136
+ }
137
+ /**
138
+ * Pad to a visible width, so styled text lines up with plain text.
139
+ *
140
+ * `known` skips re-measuring when the caller already has the width, which the
141
+ * table always does after truncating.
142
+ */
143
+ function pad(text, width, align, known) {
144
+ const filler = ' '.repeat(Math.max(0, width - (known ?? displayWidth(text))));
145
+ if (align === 'right') {
146
+ return filler + text;
147
+ }
148
+ if (align === 'center') {
149
+ const left = Math.floor(filler.length / 2);
150
+ return ' '.repeat(left) + text + ' '.repeat(filler.length - left);
151
+ }
152
+ return text + filler;
153
+ }
154
+ /**
155
+ * Truncate to a visible width, ending in an ellipsis when it does not fit.
156
+ *
157
+ * Walks code points rather than code units, so a surrogate pair is never split
158
+ * into a lone half, and a wide character is never counted as one column.
159
+ */
160
+ export function truncate(text, width) {
161
+ return fit(text, width).text;
162
+ }
163
+ /** Truncate and report the resulting visible width, measuring only once. */
164
+ function fit(text, width) {
165
+ const actual = displayWidth(text);
166
+ if (width <= 0) {
167
+ return { text: '', width: 0 };
168
+ }
169
+ if (actual <= width) {
170
+ return { text, width: actual };
171
+ }
172
+ if (width === 1) {
173
+ return { text: '…', width: 1 };
174
+ }
175
+ const budget = width - 1;
176
+ let used = 0;
177
+ let out = '';
178
+ for (const char of stripAnsi(text)) {
179
+ const cost = codePointWidth(char.codePointAt(0));
180
+ if (used + cost > budget) {
181
+ break;
182
+ }
183
+ used += cost;
184
+ out += char;
185
+ }
186
+ return { text: `${out}…`, width: used + 1 };
187
+ }
188
+ function terminalWidth() {
189
+ const columns = process.stdout?.columns;
190
+ return typeof columns === 'number' && columns > 0 ? columns : 80;
191
+ }
192
+ /** Whether the destination can show colour, matching how help decides. */
193
+ export function colorEnabled() {
194
+ return styleText('red', 'x') !== 'x';
195
+ }
196
+ function defaultCell(value) {
197
+ return value === undefined || value === null ? '' : String(value);
198
+ }
199
+ /**
200
+ * Render rows as aligned columns.
201
+ *
202
+ * Columns are sized to their widest cell, then shrunk from the widest down
203
+ * until the whole table fits the available width. Nothing is truncated while
204
+ * anything still fits, so a narrow terminal costs the sprawling column first
205
+ * rather than every column equally.
206
+ */
207
+ export function table(rows, opts) {
208
+ const { columns } = opts;
209
+ if (columns.length === 0) {
210
+ return '';
211
+ }
212
+ const gap = opts.gap ?? 2;
213
+ const indent = opts.indent ?? 0;
214
+ const showHeader = opts.header !== false;
215
+ const headerStyle = opts.headerStyle ?? (colorEnabled() ? (t) => styleText('bold', t) : (t) => t);
216
+ const headers = columns.map((c) => c.header ?? c.key);
217
+ const cells = rows.map((row) => columns.map((c) => (c.render ? c.render(row[c.key], row) : defaultCell(row[c.key]))));
218
+ const widths = columns.map((c, i) => {
219
+ let width = showHeader ? displayWidth(headers[i]) : 0;
220
+ for (const line of cells) {
221
+ width = Math.max(width, displayWidth(line[i]));
222
+ }
223
+ return c.maxWidth === undefined ? width : Math.min(width, c.maxWidth);
224
+ });
225
+ // Shrink the widest column repeatedly until the table fits.
226
+ const available = (opts.width ?? terminalWidth()) - indent;
227
+ const overhead = gap * (columns.length - 1);
228
+ let total = widths.reduce((sum, w) => sum + w, 0) + overhead;
229
+ while (total > available) {
230
+ let widest = 0;
231
+ for (let i = 1; i < widths.length; i++) {
232
+ if (widths[i] > widths[widest]) {
233
+ widest = i;
234
+ }
235
+ }
236
+ if (widths[widest] <= 3) {
237
+ break;
238
+ }
239
+ widths[widest]--;
240
+ total--;
241
+ }
242
+ const spacer = ' '.repeat(gap);
243
+ const prefix = ' '.repeat(indent);
244
+ const lines = [];
245
+ const renderRow = (values, style) => {
246
+ const parts = values.map((value, i) => {
247
+ const cut = fit(value, widths[i]);
248
+ // Styling adds escapes but no columns, so the measured width still holds.
249
+ const shown = style ? style(cut.text) : cut.text;
250
+ return pad(shown, widths[i], columns[i]?.align ?? 'left', cut.width);
251
+ });
252
+ return (prefix + parts.join(spacer)).trimEnd();
253
+ };
254
+ if (showHeader) {
255
+ lines.push(renderRow(headers, headerStyle));
256
+ if (opts.rule === true) {
257
+ lines.push(prefix + widths.map((w) => '─'.repeat(w)).join(spacer));
258
+ }
259
+ }
260
+ for (const line of cells) {
261
+ lines.push(renderRow(line));
262
+ }
263
+ return `${lines.join('\n')}\n`;
264
+ }
265
+ /**
266
+ * Render pairs as an aligned block, wrapping long values under their key.
267
+ *
268
+ * ```
269
+ * Name: clap-ts
270
+ * Version: 0.3.0
271
+ * ```
272
+ */
273
+ export function keyValue(pairs, opts) {
274
+ const entries = Array.isArray(pairs)
275
+ ? pairs
276
+ : Object.entries(pairs);
277
+ if (entries.length === 0) {
278
+ return '';
279
+ }
280
+ const separator = opts?.separator ?? ': ';
281
+ const indent = opts?.indent ?? 0;
282
+ const keyStyle = opts?.keyStyle ?? (colorEnabled() ? (t) => styleText('bold', t) : (t) => t);
283
+ const keyWidth = Math.max(...entries.map(([key]) => displayWidth(key)));
284
+ const valueColumn = indent + keyWidth + separator.length;
285
+ const available = Math.max(20, (opts?.width ?? terminalWidth()) - valueColumn);
286
+ const lines = [];
287
+ for (const [key, value] of entries) {
288
+ const label = ' '.repeat(indent) + keyStyle(key + separator) + ' '.repeat(keyWidth - displayWidth(key));
289
+ const wrapped = wrap(value, available);
290
+ lines.push((label + wrapped[0]).trimEnd());
291
+ for (const rest of wrapped.slice(1)) {
292
+ lines.push(' '.repeat(valueColumn) + rest);
293
+ }
294
+ }
295
+ return `${lines.join('\n')}\n`;
296
+ }
297
+ /** Break text into lines no wider than `width`, on whitespace. */
298
+ function wrap(text, width) {
299
+ if (displayWidth(text) <= width) {
300
+ return [text];
301
+ }
302
+ const lines = [];
303
+ let current = '';
304
+ for (const word of text.split(/\s+/)) {
305
+ if (current.length === 0) {
306
+ current = word;
307
+ }
308
+ else if (displayWidth(current) + 1 + displayWidth(word) <= width) {
309
+ current += ` ${word}`;
310
+ }
311
+ else {
312
+ lines.push(current);
313
+ current = word;
314
+ }
315
+ }
316
+ if (current.length > 0) {
317
+ lines.push(current);
318
+ }
319
+ return lines;
320
+ }
321
+ /**
322
+ * Render a tree with connector lines.
323
+ *
324
+ * ```
325
+ * root
326
+ * ├── one
327
+ * │ └── nested
328
+ * └── two
329
+ * ```
330
+ */
331
+ export function tree(root, opts) {
332
+ const ascii = opts?.ascii === true;
333
+ const glyphs = ascii
334
+ ? { branch: '|-- ', last: '`-- ', pipe: '| ', blank: ' ' }
335
+ : { branch: '├── ', last: '└── ', pipe: '│ ', blank: ' ' };
336
+ const prefix = ' '.repeat(opts?.indent ?? 0);
337
+ const lines = [];
338
+ const walk = (node, ancestry, isLast, isRoot) => {
339
+ if (isRoot) {
340
+ lines.push(prefix + node.label);
341
+ }
342
+ else {
343
+ lines.push(prefix + ancestry + (isLast ? glyphs.last : glyphs.branch) + node.label);
344
+ }
345
+ const children = node.children ?? [];
346
+ const childAncestry = isRoot ? '' : ancestry + (isLast ? glyphs.blank : glyphs.pipe);
347
+ children.forEach((child, i) => {
348
+ walk(child, childAncestry, i === children.length - 1, false);
349
+ });
350
+ };
351
+ const roots = Array.isArray(root) ? root : [root];
352
+ roots.forEach((node) => {
353
+ walk(node, '', true, true);
354
+ });
355
+ return `${lines.join('\n')}\n`;
356
+ }