@lewishowles/cli-style 0.8.0 → 0.9.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/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.9.0 - 2026-07-16
6
+
7
+ ### Added
8
+
9
+ - Added a `rowGroup` primitive (`rowGroup()` in JS, the `row-group` renderer for `cli-style render`, and the same generic `render()`/`cli_style_render`/`CliStyle.render` entry points in Python, Bash, and Swift) that aligns a group of related rows automatically, without the caller computing `labelWidth` by hand.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed the Python, Bash, and Swift adapters silently dropping `row()`, `span()`, and `divider()` options beyond their two or three main fields. `row()` now forwards `labelWidth`, `labelColour`, `valueColour`, and `separator`; `span()` forwards `weight`; `divider()` forwards `dividerWidth`, `dividerColour`, and `labelColour`.
14
+
15
+ ## 0.8.1 - 2026-07-15
16
+
17
+ ### Fixed
18
+
19
+ - Fixed the Python and Swift adapters losing ANSI colour: they now set `FORCE_COLOR=1` for the child process when the caller's own stdout is a TTY, unless the caller has already set `FORCE_COLOR` or `NO_COLOR`.
20
+ - Documented that captured Bash output (command substitution) cannot auto-detect its eventual destination and requires an explicit `FORCE_COLOR=1` to opt into colour.
21
+
5
22
  ## 0.8.0 - 2026-07-05
6
23
 
7
24
  ### Added
package/README.md CHANGED
@@ -107,6 +107,14 @@ cli_style_next_step_block "Update helpers scripts" "Wrappers are now available"
107
107
 
108
108
  The convenience functions build JSON internally, so callers do not need to escape quotes, backslashes, or paths by hand.
109
109
 
110
+ Direct Bash calls use `cli-style`'s native stdout detection. When a helper's output is captured, the adapter cannot know where the captured text will be written, so set `FORCE_COLOR=1` explicitly when the destination is an interactive terminal:
111
+
112
+ ```bash
113
+ command="$(FORCE_COLOR=1 cli_style_span "npm run docs:readme" info)"
114
+ ```
115
+
116
+ Python and Swift automatically set `FORCE_COLOR=1` for the child render process when their caller's stdout is an interactive terminal. An existing `FORCE_COLOR` or `NO_COLOR` value is preserved. `FORCE_COLOR=0` does not enable colour, while `NO_COLOR`, `TERM=dumb`, and explicit no-colour flags remain authoritative.
117
+
110
118
  Set `CLI_STYLE_BIN` when `cli-style` is not on `PATH`:
111
119
 
112
120
  ```bash
@@ -219,6 +227,7 @@ Renderer names are stable for `cli-style render`, `cli_style_render`, and Python
219
227
  | --------------------- | ------------------------------------- | ------------------------------------ | ---------------------------------- | -------------------------------------- | --------------------------------------------------------------- |
220
228
  | `status` | `status(resultType, detail, options)` | `cli_style_status type label detail` | `status(type, label, detail)` | `CliStyle.status(type, label, detail)` | Result line with symbol, label, and detail. |
221
229
  | `row` | `row(label, value, options)` | `cli_style_row label value [result]` | `row(label, value, result)` | `CliStyle.row(label, value, result)` | Aligned label/value row, optionally marked with a result state. |
230
+ | `row-group` | `rowGroup(options)` | Use `cli_style_render` | Use `render("row-group", ...)` | Use `CliStyle.render` | Auto-aligned group of labelled value rows. |
222
231
  | `span` | `span(value, tone, options)` | `cli_style_span value [tone]` | `span(value, tone)` | `CliStyle.span(value, tone)` | Inline colour or weight for a word, command, file, or value. |
223
232
  | `hint` | `hint(message, options)` | `cli_style_hint message` | `hint(message)` | `CliStyle.hint(message)` | Informational hint line. |
224
233
  | `divider` | `divider(options)` | `cli_style_divider label` | `divider(label)` | `CliStyle.divider(label)` | Section divider with an optional label. |
@@ -274,6 +283,27 @@ Use these shapes with `cli-style render`, `cli_style_render`, or Python `render(
274
283
  - `labelWidth`: optional minimum label width
275
284
  - `labelColour`, `valueColour`, `separator`: optional presentation overrides
276
285
 
286
+ ### `row-group`
287
+
288
+ ```json
289
+ {
290
+ "rows": [
291
+ {
292
+ "label": "Package",
293
+ "value": "@lewishowles/components"
294
+ },
295
+ {
296
+ "label": "Version",
297
+ "value": "minor: 2.7.4 -> 2.8.0"
298
+ }
299
+ ]
300
+ }
301
+ ```
302
+
303
+ - `rows`: labelled values to align and render
304
+ - `labelWidth`: optional minimum width for every label
305
+ - Per-row fields match `row`, including `label`, `value`, `result`, `labelColour`, `valueColour`, and `separator`
306
+
277
307
  ### `span`
278
308
 
279
309
  ```json
@@ -506,7 +536,7 @@ Most renderers accept the same output options:
506
536
  | `unicode` | Whether Unicode symbols should be used. |
507
537
  | `width` | Available output width for renderers that adapt layout. |
508
538
 
509
- Use `createCliStyle()` to resolve these options from `argv`, `env`, `stdout`, CI, TTY, `NO_COLOR`, and terminal capability.
539
+ Use `createCliStyle()` to resolve these options from `argv`, `env`, `stdout`, CI, TTY, `NO_COLOR`, `FORCE_COLOR`, and terminal capability.
510
540
 
511
541
  ### CLI and Bash flags
512
542
 
@@ -159,6 +159,11 @@ cli_style_row() {
159
159
  local label="${1:-}"
160
160
  local value="${2:-}"
161
161
  local result=""
162
+ local label_width=""
163
+ local label_colour_json="null"
164
+ local value_colour_json="null"
165
+ local separator_json="null"
166
+ local -a render_args=()
162
167
  local json
163
168
 
164
169
  if (($# >= 3)) && [[ "${3:-}" != --* ]]; then
@@ -170,9 +175,60 @@ cli_style_row() {
170
175
  set --
171
176
  fi
172
177
 
173
- json="{\"label\":$(cli_style_json_string "$label"),\"value\":$(cli_style_json_string "$value"),\"result\":$(cli_style_json_string "$result")}"
178
+ while (($# > 0)); do
179
+ case "$1" in
180
+ --label-width)
181
+ label_width="${2:-}"
182
+ shift 2
183
+ ;;
184
+ --label-width=*)
185
+ label_width="${1#*=}"
186
+ shift
187
+ ;;
188
+ --label-colour)
189
+ label_colour_json="$(cli_style_json_string "${2:-}")"
190
+ shift 2
191
+ ;;
192
+ --label-colour=*)
193
+ label_colour_json="$(cli_style_json_string "${1#*=}")"
194
+ shift
195
+ ;;
196
+ --value-colour)
197
+ value_colour_json="$(cli_style_json_string "${2:-}")"
198
+ shift 2
199
+ ;;
200
+ --value-colour=*)
201
+ value_colour_json="$(cli_style_json_string "${1#*=}")"
202
+ shift
203
+ ;;
204
+ --separator)
205
+ separator_json="$(cli_style_json_string "${2:-}")"
206
+ shift 2
207
+ ;;
208
+ --separator=*)
209
+ separator_json="$(cli_style_json_string "${1#*=}")"
210
+ shift
211
+ ;;
212
+ *)
213
+ render_args+=("$1")
214
+ shift
215
+ ;;
216
+ esac
217
+ done
218
+
219
+ json="{\"label\":$(cli_style_json_string "$label"),\"value\":$(cli_style_json_string "$value"),\"result\":$(cli_style_json_string "$result"),\"labelWidth\":$(cli_style_json_integer_or_null "$label_width"),\"separator\":$separator_json"
220
+
221
+ if [[ "$label_colour_json" != "null" ]]; then
222
+ json="${json},\"labelColour\":$label_colour_json"
223
+ fi
174
224
 
175
- cli_style_render_json row "$json" "$@"
225
+ if [[ "$value_colour_json" != "null" ]]; then
226
+ json="${json},\"valueColour\":$value_colour_json"
227
+ fi
228
+
229
+ json="${json}}"
230
+
231
+ cli_style_render_json row "$json" "${render_args[@]}"
176
232
  }
177
233
 
178
234
  # Render an inline span from Bash string values.
@@ -186,6 +242,8 @@ cli_style_row() {
186
242
  cli_style_span() {
187
243
  local value="${1:-}"
188
244
  local tone="info"
245
+ local weight_json="null"
246
+ local -a render_args=()
189
247
  local json
190
248
 
191
249
  if (($# >= 2)) && [[ "${2:-}" != --* ]]; then
@@ -197,9 +255,26 @@ cli_style_span() {
197
255
  set --
198
256
  fi
199
257
 
200
- json="{\"value\":$(cli_style_json_string "$value"),\"tone\":$(cli_style_json_string "$tone")}"
258
+ while (($# > 0)); do
259
+ case "$1" in
260
+ --weight)
261
+ weight_json="$(cli_style_json_string "${2:-}")"
262
+ shift 2
263
+ ;;
264
+ --weight=*)
265
+ weight_json="$(cli_style_json_string "${1#*=}")"
266
+ shift
267
+ ;;
268
+ *)
269
+ render_args+=("$1")
270
+ shift
271
+ ;;
272
+ esac
273
+ done
274
+
275
+ json="{\"value\":$(cli_style_json_string "$value"),\"tone\":$(cli_style_json_string "$tone"),\"weight\":$weight_json}"
201
276
 
202
- cli_style_render_json span "$json" "$@"
277
+ cli_style_render_json span "$json" "${render_args[@]}"
203
278
  }
204
279
 
205
280
  # Render a divider from a Bash string value.
@@ -210,6 +285,10 @@ cli_style_span() {
210
285
  # Render flags passed through to `cli-style render`.
211
286
  cli_style_divider() {
212
287
  local label="${1:-}"
288
+ local divider_width=""
289
+ local divider_colour_json="null"
290
+ local label_colour_json="null"
291
+ local -a render_args=()
213
292
  local json
214
293
 
215
294
  if (($# >= 1)); then
@@ -218,9 +297,52 @@ cli_style_divider() {
218
297
  set --
219
298
  fi
220
299
 
221
- json="{\"label\":$(cli_style_json_string "$label")}"
300
+ while (($# > 0)); do
301
+ case "$1" in
302
+ --divider-width)
303
+ divider_width="${2:-}"
304
+ shift 2
305
+ ;;
306
+ --divider-width=*)
307
+ divider_width="${1#*=}"
308
+ shift
309
+ ;;
310
+ --divider-colour)
311
+ divider_colour_json="$(cli_style_json_string "${2:-}")"
312
+ shift 2
313
+ ;;
314
+ --divider-colour=*)
315
+ divider_colour_json="$(cli_style_json_string "${1#*=}")"
316
+ shift
317
+ ;;
318
+ --label-colour)
319
+ label_colour_json="$(cli_style_json_string "${2:-}")"
320
+ shift 2
321
+ ;;
322
+ --label-colour=*)
323
+ label_colour_json="$(cli_style_json_string "${1#*=}")"
324
+ shift
325
+ ;;
326
+ *)
327
+ render_args+=("$1")
328
+ shift
329
+ ;;
330
+ esac
331
+ done
332
+
333
+ json="{\"label\":$(cli_style_json_string "$label"),\"dividerWidth\":$(cli_style_json_integer_or_null "$divider_width")"
334
+
335
+ if [[ "$divider_colour_json" != "null" ]]; then
336
+ json="${json},\"dividerColour\":$divider_colour_json"
337
+ fi
338
+
339
+ if [[ "$label_colour_json" != "null" ]]; then
340
+ json="${json},\"labelColour\":$label_colour_json"
341
+ fi
342
+
343
+ json="${json}}"
222
344
 
223
- cli_style_render_json divider "$json" "$@"
345
+ cli_style_render_json divider "$json" "${render_args[@]}"
224
346
  }
225
347
 
226
348
  # Render a hint from a Bash string value.
@@ -7,6 +7,7 @@ import json
7
7
  import os
8
8
  import shutil
9
9
  import subprocess
10
+ import sys
10
11
  from pathlib import Path
11
12
  from typing import Any, Sequence
12
13
 
@@ -23,6 +24,20 @@ class CliStyleRenderError(CliStyleError):
23
24
  """Raised when cli-style rejects a render request."""
24
25
 
25
26
 
27
+ # Resolve the child environment while preserving explicit caller controls.
28
+ def resolve_subprocess_environment() -> dict[str, str]:
29
+ environment = os.environ.copy()
30
+
31
+ if (
32
+ sys.stdout.isatty()
33
+ and "FORCE_COLOR" not in environment
34
+ and "NO_COLOR" not in environment
35
+ ):
36
+ environment["FORCE_COLOR"] = "1"
37
+
38
+ return environment
39
+
40
+
26
41
  # Render caller data through `cli-style render`.
27
42
  #
28
43
  # @param {str} renderer
@@ -72,6 +87,7 @@ def render(
72
87
  command,
73
88
  capture_output=True,
74
89
  check=False,
90
+ env=resolve_subprocess_environment(),
75
91
  input=json.dumps(data),
76
92
  text=True,
77
93
  )
@@ -108,13 +124,39 @@ def status(
108
124
  # Row value.
109
125
  # @param {str} result
110
126
  # Optional result type for a symbol and tone.
127
+ # @param {int|None} label_width
128
+ # Optional minimum label width.
129
+ # @param {str|None} label_colour
130
+ # Optional label colour token.
131
+ # @param {str|None} value_colour
132
+ # Optional value colour token.
133
+ # @param {str|None} separator
134
+ # Optional text between the label and value.
111
135
  def row(
112
136
  label: str,
113
137
  value: str,
114
138
  result: str = "",
139
+ label_width: int | None = None,
140
+ label_colour: str | None = None,
141
+ value_colour: str | None = None,
142
+ separator: str | None = None,
115
143
  **kwargs: Any,
116
144
  ) -> str:
117
- return render("row", {"label": label, "value": value, "result": result}, **kwargs)
145
+ data = {
146
+ "label": label,
147
+ "value": value,
148
+ "result": result,
149
+ "labelWidth": label_width,
150
+ "separator": separator,
151
+ }
152
+
153
+ if label_colour is not None:
154
+ data["labelColour"] = label_colour
155
+
156
+ if value_colour is not None:
157
+ data["valueColour"] = value_colour
158
+
159
+ return render("row", data, **kwargs)
118
160
 
119
161
 
120
162
  # Render an inline span from Python values.
@@ -123,23 +165,46 @@ def row(
123
165
  # Span text.
124
166
  # @param {str} tone
125
167
  # Optional colour tone, defaulting to info.
168
+ # @param {str|None} weight
169
+ # Optional ANSI text weight.
126
170
  def span(
127
171
  value: str,
128
172
  tone: str = "info",
173
+ weight: str | None = None,
129
174
  **kwargs: Any,
130
175
  ) -> str:
131
- return render("span", {"value": value, "tone": tone}, **kwargs)
176
+ return render("span", {"value": value, "tone": tone, "weight": weight}, **kwargs)
132
177
 
133
178
 
134
179
  # Render a divider from a Python value.
135
180
  #
136
181
  # @param {str} label
137
182
  # Optional divider label.
183
+ # @param {int|None} divider_width
184
+ # Optional divider width.
185
+ # @param {str|None} divider_colour
186
+ # Optional divider colour token.
187
+ # @param {str|None} label_colour
188
+ # Optional label colour token.
138
189
  def divider(
139
190
  label: str = "",
191
+ divider_width: int | None = None,
192
+ divider_colour: str | None = None,
193
+ label_colour: str | None = None,
140
194
  **kwargs: Any,
141
195
  ) -> str:
142
- return render("divider", {"label": label}, **kwargs)
196
+ data = {
197
+ "label": label,
198
+ "dividerWidth": divider_width,
199
+ }
200
+
201
+ if divider_colour is not None:
202
+ data["dividerColour"] = divider_colour
203
+
204
+ if label_colour is not None:
205
+ data["labelColour"] = label_colour
206
+
207
+ return render("divider", data, **kwargs)
143
208
 
144
209
 
145
210
  # Render a hint from a Python value.
@@ -1,4 +1,9 @@
1
1
  import Foundation
2
+ #if canImport(Darwin)
3
+ import Darwin
4
+ #elseif canImport(Glibc)
5
+ import Glibc
6
+ #endif
2
7
 
3
8
  // Error types for cli-style adapter failures.
4
9
  enum CliStyleError: Error {
@@ -89,6 +94,7 @@ enum CliStyle {
89
94
  let process = Process()
90
95
  process.executableURL = URL(fileURLWithPath: resolvedBinary)
91
96
  process.arguments = arguments
97
+ process.environment = resolveProcessEnvironment()
92
98
 
93
99
  let stdinPipe = Pipe()
94
100
  let stdoutPipe = Pipe()
@@ -125,6 +131,21 @@ enum CliStyle {
125
131
  return stdoutText.trimmingCharacters(in: .newlines)
126
132
  }
127
133
 
134
+ /**
135
+ * Resolve the child environment for a captured render.
136
+ */
137
+ private static func resolveProcessEnvironment() -> [String: String] {
138
+ var environment = ProcessInfo.processInfo.environment
139
+
140
+ if isatty(STDOUT_FILENO) == 1 &&
141
+ environment["FORCE_COLOR"] == nil &&
142
+ environment["NO_COLOR"] == nil {
143
+ environment["FORCE_COLOR"] = "1"
144
+ }
145
+
146
+ return environment
147
+ }
148
+
128
149
  /**
129
150
  Render a result status line.
130
151
 
@@ -154,6 +175,10 @@ enum CliStyle {
154
175
  - parameter label: Row label.
155
176
  - parameter value: Row value.
156
177
  - parameter result: Optional result type for a symbol and tone.
178
+ - parameter labelWidth: Optional minimum label width.
179
+ - parameter labelColour: Optional label colour token.
180
+ - parameter valueColour: Optional value colour token.
181
+ - parameter separator: Optional text between the label and value.
157
182
  - parameter options: Render options.
158
183
  - returns: Rendered row line.
159
184
  - throws: `CliStyleError` on failure.
@@ -162,13 +187,35 @@ enum CliStyle {
162
187
  label: String,
163
188
  value: String,
164
189
  result: String = "",
190
+ labelWidth: Int? = nil,
191
+ labelColour: String? = nil,
192
+ valueColour: String? = nil,
193
+ separator: String? = nil,
165
194
  options: CliStyleOptions = .init()
166
195
  ) throws -> String {
167
- try render("row", data: [
196
+ var data: [String: Any] = [
168
197
  "label": label,
169
198
  "value": value,
170
199
  "result": result,
171
- ], options: options)
200
+ ]
201
+
202
+ if let labelWidth = labelWidth {
203
+ data["labelWidth"] = labelWidth
204
+ }
205
+
206
+ if let labelColour = labelColour {
207
+ data["labelColour"] = labelColour
208
+ }
209
+
210
+ if let valueColour = valueColour {
211
+ data["valueColour"] = valueColour
212
+ }
213
+
214
+ if let separator = separator {
215
+ data["separator"] = separator
216
+ }
217
+
218
+ return try render("row", data: data, options: options)
172
219
  }
173
220
 
174
221
  /**
@@ -176,6 +223,7 @@ enum CliStyle {
176
223
 
177
224
  - parameter value: Span text.
178
225
  - parameter tone: Optional colour tone, defaulting to info.
226
+ - parameter weight: Optional ANSI text weight.
179
227
  - parameter options: Render options.
180
228
  - returns: Rendered span string.
181
229
  - throws: `CliStyleError` on failure.
@@ -183,29 +231,56 @@ enum CliStyle {
183
231
  static func span(
184
232
  value: String,
185
233
  tone: String = "info",
234
+ weight: String? = nil,
186
235
  options: CliStyleOptions = .init()
187
236
  ) throws -> String {
188
- try render("span", data: [
237
+ var data: [String: Any] = [
189
238
  "value": value,
190
239
  "tone": tone,
191
- ], options: options)
240
+ ]
241
+
242
+ if let weight = weight {
243
+ data["weight"] = weight
244
+ }
245
+
246
+ return try render("span", data: data, options: options)
192
247
  }
193
248
 
194
249
  /**
195
250
  Render a divider.
196
251
 
197
252
  - parameter label: Optional divider label.
253
+ - parameter dividerWidth: Optional divider width.
254
+ - parameter dividerColour: Optional divider colour token.
255
+ - parameter labelColour: Optional label colour token.
198
256
  - parameter options: Render options.
199
257
  - returns: Rendered divider line.
200
258
  - throws: `CliStyleError` on failure.
201
259
  */
202
260
  static func divider(
203
261
  label: String = "",
262
+ dividerWidth: Int? = nil,
263
+ dividerColour: String? = nil,
264
+ labelColour: String? = nil,
204
265
  options: CliStyleOptions = .init()
205
266
  ) throws -> String {
206
- try render("divider", data: [
267
+ var data: [String: Any] = [
207
268
  "label": label,
208
- ], options: options)
269
+ ]
270
+
271
+ if let dividerWidth = dividerWidth {
272
+ data["dividerWidth"] = dividerWidth
273
+ }
274
+
275
+ if let dividerColour = dividerColour {
276
+ data["dividerColour"] = dividerColour
277
+ }
278
+
279
+ if let labelColour = labelColour {
280
+ data["labelColour"] = labelColour
281
+ }
282
+
283
+ return try render("divider", data: data, options: options)
209
284
  }
210
285
 
211
286
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lewishowles/cli-style",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Shared terminal output styles for Lewis Howles tools, diagnostics, and package CLIs.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,12 +23,14 @@ export function resolveTerminalCapabilities(options = {}) {
23
23
  const isTty = stdout.isTTY === true;
24
24
  const isCi = env.CI === "true" || env.CI === "1";
25
25
  const isDumb = env.TERM === "dumb";
26
+ const forceColour = env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== "0";
26
27
  const width = resolveWidth(stdout.columns);
27
28
  const usesPlain = argv.includes("--plain");
28
29
 
29
30
  const colour = resolveColour({
30
31
  argv,
31
32
  env,
33
+ forceColour,
32
34
  isDumb,
33
35
  isTty,
34
36
  usesPlain,
@@ -69,7 +71,7 @@ function resolveColour(options) {
69
71
  return false;
70
72
  }
71
73
 
72
- return options.isTty;
74
+ return options.forceColour || options.isTty;
73
75
  }
74
76
 
75
77
  /**
@@ -13,6 +13,7 @@ import { emptyState, errorBlock, hint } from "../primitives/feedback.js";
13
13
  import { panel } from "../primitives/panel.js";
14
14
  import { progressBar } from "../primitives/progress-bar.js";
15
15
  import { row } from "../primitives/row.js";
16
+ import { rowGroup } from "../primitives/row-group.js";
16
17
  import { span } from "../primitives/span.js";
17
18
  import { status } from "../primitives/status.js";
18
19
  import { step, stepProgress } from "../primitives/step-progress.js";
@@ -73,6 +74,11 @@ const renderers = {
73
74
  ...input,
74
75
  ...options,
75
76
  }),
77
+ "row-group": (input, options) =>
78
+ rowGroup({
79
+ ...input,
80
+ ...options,
81
+ }),
76
82
  span: (input, options) =>
77
83
  span(input.value, input.tone, {
78
84
  ...input,
@@ -14,6 +14,7 @@ import { emptyState, errorBlock, hint } from "../primitives/feedback.js";
14
14
  import { panel } from "../primitives/panel.js";
15
15
  import { progressBar } from "../primitives/progress-bar.js";
16
16
  import { row } from "../primitives/row.js";
17
+ import { rowGroup } from "../primitives/row-group.js";
17
18
  import { span } from "../primitives/span.js";
18
19
  import { status } from "../primitives/status.js";
19
20
  import { step, stepProgress, stepStates } from "../primitives/step-progress.js";
@@ -715,6 +716,30 @@ function renderPrimitives(options) {
715
716
  labelWidth: 8,
716
717
  result: resultTypes.FAILED,
717
718
  }),
719
+ "",
720
+ "Row groups",
721
+ rowGroup({
722
+ ...options,
723
+ rows: [
724
+ {
725
+ label: "Package",
726
+ value: "@lewishowles/components",
727
+ },
728
+ {
729
+ label: "Version",
730
+ value: "minor: 2.7.4 -> 2.8.0",
731
+ },
732
+ {
733
+ label: "Checks",
734
+ value: "type-check, unit, build",
735
+ },
736
+ {
737
+ label: "Bundle",
738
+ result: resultTypes.FAILED,
739
+ value: "is 22.3 KB, above the 12.0 KB budget",
740
+ },
741
+ ],
742
+ }),
718
743
  ].join("\n");
719
744
  }
720
745
 
package/src/index.js CHANGED
@@ -35,6 +35,7 @@ export { emptyState, errorBlock, hint } from "./primitives/feedback.js";
35
35
  export { panel } from "./primitives/panel.js";
36
36
  export { progressBar } from "./primitives/progress-bar.js";
37
37
  export { row } from "./primitives/row.js";
38
+ export { rowGroup } from "./primitives/row-group.js";
38
39
  export { span } from "./primitives/span.js";
39
40
  export { status } from "./primitives/status.js";
40
41
  export { step, stepProgress, stepStates } from "./primitives/step-progress.js";
@@ -0,0 +1,84 @@
1
+ import { getResultSymbol } from "../theme/results.js";
2
+ import { row } from "./row.js";
3
+
4
+ /**
5
+ * Render related labelled values against a shared label width.
6
+ *
7
+ * @param {object} options
8
+ * Rendering options.
9
+ * @param {number} options.labelWidth
10
+ * Optional minimum label width.
11
+ * @param {object[]} options.rows
12
+ * Labelled values to render.
13
+ * @returns {string}
14
+ * Rendered row group.
15
+ */
16
+ export function rowGroup(options = {}) {
17
+ const rows = normaliseRows(options.rows);
18
+
19
+ if (rows.length === 0) {
20
+ return "";
21
+ }
22
+
23
+ const labelWidth = Math.max(
24
+ ...rows.map((entry) => displayedLabelLength(entry, options)),
25
+ options.labelWidth ?? 0,
26
+ );
27
+
28
+ return rows
29
+ .map((entry) =>
30
+ row(entry.label, entry.value ?? "", {
31
+ ...options,
32
+ ...entry,
33
+ labelWidth,
34
+ }),
35
+ )
36
+ .join("\n");
37
+ }
38
+
39
+ /**
40
+ * Return rows with usable labels.
41
+ *
42
+ * @param {object[]} rows
43
+ * Requested row entries.
44
+ * @returns {object[]}
45
+ * Valid row entries.
46
+ */
47
+ function normaliseRows(rows) {
48
+ if (!Array.isArray(rows)) {
49
+ return [];
50
+ }
51
+
52
+ return rows.filter(
53
+ (entry) =>
54
+ entry !== null &&
55
+ typeof entry === "object" &&
56
+ typeof entry.label === "string" &&
57
+ entry.label !== "",
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Return the visible width of a row label.
63
+ *
64
+ * @param {object} entry
65
+ * Row entry.
66
+ * @param {object} options
67
+ * Group rendering options.
68
+ * @returns {number}
69
+ * Displayed label length.
70
+ */
71
+ function displayedLabelLength(entry, options) {
72
+ if (typeof entry.result !== "string" || entry.result === "") {
73
+ return entry.label.length;
74
+ }
75
+
76
+ const entryOptions = {
77
+ ...options,
78
+ ...entry,
79
+ };
80
+
81
+ const symbol = getResultSymbol(entry.result, entryOptions);
82
+
83
+ return [symbol, entry.label].join(" ").length;
84
+ }