@effected/cli 0.9.0 → 0.11.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.
Files changed (90) hide show
  1. package/Cancelled.js +44 -0
  2. package/CliAudience.js +178 -0
  3. package/CliColor.js +14 -20
  4. package/CliEnv.js +89 -0
  5. package/CliExit.js +1 -1
  6. package/CliFailure.js +253 -0
  7. package/CliInteractive.js +71 -0
  8. package/CliLinks.js +154 -0
  9. package/CliLog.js +294 -0
  10. package/CliLogger.js +34 -33
  11. package/CliMessage.js +83 -0
  12. package/CliPrompt.js +104 -0
  13. package/CliRuntime.js +104 -54
  14. package/CliTest.js +18 -2
  15. package/CliTheme.js +128 -0
  16. package/ConfigIssueRenderer.js +14 -33
  17. package/Doc.js +512 -0
  18. package/Fmt.js +133 -0
  19. package/GithubAnnotation.js +40 -0
  20. package/Glyphs.js +83 -0
  21. package/NotInteractive.js +42 -0
  22. package/README.md +129 -131
  23. package/Render.js +254 -0
  24. package/SchemaIssueRenderer.js +7 -10
  25. package/Status.js +163 -0
  26. package/TestTerminal.js +80 -0
  27. package/Token.js +69 -0
  28. package/index.d.ts +2923 -171
  29. package/index.js +19 -1
  30. package/internal/HelpRouting.js +1 -1
  31. package/internal/ansi.js +230 -0
  32. package/internal/autoFormat.js +34 -0
  33. package/internal/canPrompt.js +15 -0
  34. package/internal/counts.js +69 -0
  35. package/internal/diagnostics.js +32 -0
  36. package/internal/displayWidth.js +35 -0
  37. package/internal/failureTarget.js +156 -0
  38. package/internal/fallbackAnswer.js +18 -0
  39. package/internal/fileSink.js +62 -0
  40. package/internal/format.js +62 -7
  41. package/internal/layout.js +250 -0
  42. package/internal/linkScheme.js +30 -0
  43. package/internal/linkTarget.js +50 -0
  44. package/internal/logSafety.js +46 -0
  45. package/internal/renderAnsi.js +52 -0
  46. package/internal/renderDoc.js +319 -0
  47. package/internal/renderGithubLog.js +46 -0
  48. package/internal/renderMarkdown.js +367 -0
  49. package/internal/renderPlain.js +50 -0
  50. package/internal/scanAudience.js +106 -0
  51. package/internal/splitFrame.js +56 -0
  52. package/internal/wizardGate.js +18 -0
  53. package/package.json +35 -5
  54. package/testing.d.ts +90 -4
  55. package/testing.js +2 -1
  56. package/ui/CliUi.js +348 -0
  57. package/ui/CliUiLive.js +399 -0
  58. package/ui/Confirm.js +245 -0
  59. package/ui/DocView.js +74 -0
  60. package/ui/KeyHelp.js +62 -0
  61. package/ui/KeyTable.js +199 -0
  62. package/ui/MultiSelect.js +260 -0
  63. package/ui/Select.js +226 -0
  64. package/ui/Tabs.js +202 -0
  65. package/ui/TextInput.js +250 -0
  66. package/ui/Toggle.js +32 -0
  67. package/ui/UiKey.js +44 -0
  68. package/ui/UiProvider.js +60 -0
  69. package/ui/UiStreams.js +18 -0
  70. package/ui/UiTheme.js +119 -0
  71. package/ui/Viewport.js +204 -0
  72. package/ui/internal/ErrorBoundary.js +30 -0
  73. package/ui/internal/Holder.js +74 -0
  74. package/ui/internal/ScreenContext.js +52 -0
  75. package/ui/internal/UiProviders.js +21 -0
  76. package/ui/internal/ink.js +122 -0
  77. package/ui/internal/inkChalk.js +58 -0
  78. package/ui/internal/inkConsole.js +146 -0
  79. package/ui/internal/lineText.js +19 -0
  80. package/ui/internal/mountPermit.js +16 -0
  81. package/ui/internal/perfDrain.js +33 -0
  82. package/ui/internal/processStreams.js +19 -0
  83. package/ui/internal/renderOptions.js +13 -0
  84. package/ui/testing/CliUiTest.js +735 -0
  85. package/ui/testing/fakeStreams.js +76 -0
  86. package/ui/testing/terminalModel.js +59 -0
  87. package/ui-testing.d.ts +446 -0
  88. package/ui-testing.js +3 -0
  89. package/ui.d.ts +1648 -0
  90. package/ui.js +17 -0
package/Doc.js ADDED
@@ -0,0 +1,512 @@
1
+ import { autoFormat } from "./internal/autoFormat.js";
2
+ import { totalOf, visibleCountersOf } from "./internal/counts.js";
3
+ import { Render } from "./Render.js";
4
+ import { Console, Effect } from "effect";
5
+
6
+ //#region src/Doc.ts
7
+ const isList = (input) => Array.isArray(input);
8
+ const freeze = (value) => Object.freeze(value);
9
+ const frozenArray = (items) => Object.freeze([...items]);
10
+ const text = (value, token) => freeze({
11
+ _tag: "Text",
12
+ value,
13
+ ...token === void 0 ? {} : { token }
14
+ });
15
+ const inlineOne = (part) => typeof part === "string" ? text(part) : part;
16
+ const inlines = (input) => frozenArray(isList(input) ? input.map(inlineOne) : [inlineOne(input)]);
17
+ const overflowOf = (overflow) => (hidden) => inlines(overflow(hidden));
18
+ const overflowFields = (options) => ({
19
+ ...options?.cap === void 0 ? {} : { cap: options.cap },
20
+ ...options?.overflow === void 0 ? {} : { overflow: overflowOf(options.overflow) }
21
+ });
22
+ const treeNode = (input) => freeze({
23
+ label: inlines(input.label),
24
+ children: frozenArray((input.children ?? []).map(treeNode))
25
+ });
26
+ const counterOf = (counter) => freeze({
27
+ ...counter,
28
+ status: freeze({
29
+ name: counter.status.name,
30
+ def: freeze({ ...counter.status.def })
31
+ })
32
+ });
33
+ /**
34
+ * Constructors for the document IR, and two helpers a renderer shares.
35
+ *
36
+ * @remarks
37
+ * Every constructor returns a frozen node and copies the arrays it is given, so editing an input afterwards
38
+ * cannot change a document. An optional field that is not given is absent from the node, not `undefined`.
39
+ * Content arguments accept a string, an `Inline` or an array of either.
40
+ *
41
+ * A node is plain data: nothing decodes or encodes one, so a function field such as `overflow` or `total` is fine
42
+ * and a document is not meant to be serialised.
43
+ *
44
+ * Freezing covers what a `Doc` constructor builds. A literal you write by hand is not frozen, and a `Style` object
45
+ * given as a token is shared by reference (the freeze of a status definition is shallow for the same reason).
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * import { Doc, Status } from "@effected/cli"
50
+ *
51
+ * const report = [
52
+ * Doc.heading(2, "Results"),
53
+ * Doc.paragraph(Doc.status(Status.core, "success"), " ", "3 checks passed"),
54
+ * Doc.table([{ header: "Check" }, { header: "Time", align: "right" }], [["lint", "1.2s"]]),
55
+ * ]
56
+ * // Written for whoever is reading: `yield* Doc.print(report)`
57
+ * ```
58
+ *
59
+ * @public
60
+ */
61
+ var Doc = class {
62
+ constructor() {}
63
+ /**
64
+ * A run of text.
65
+ *
66
+ * @param value - the text
67
+ * @param token - a semantic token or a style to paint it with
68
+ */
69
+ static text(value, token) {
70
+ return text(value, token);
71
+ }
72
+ /**
73
+ * Code in a monospace span.
74
+ *
75
+ * @param value - the code
76
+ */
77
+ static code(value) {
78
+ return freeze({
79
+ _tag: "Code",
80
+ value
81
+ });
82
+ }
83
+ static link(target, label, options) {
84
+ if (target === void 0) return typeof label === "string" ? text(label) : label;
85
+ const fallback = "url" in target ? target.url : target.file;
86
+ return freeze({
87
+ _tag: "Link",
88
+ target: freeze({ ...target }),
89
+ label: inlines(label ?? fallback),
90
+ ...options?.suffix === void 0 ? {} : { suffix: options.suffix }
91
+ });
92
+ }
93
+ /**
94
+ * A status glyph, holding the resolved definition.
95
+ *
96
+ * @remarks
97
+ * A name the vocabulary does not have is a compile error.
98
+ *
99
+ * @param vocab - the vocabulary the name belongs to
100
+ * @param name - a status name in it
101
+ */
102
+ static status(vocab, name) {
103
+ return freeze({
104
+ _tag: "StatusMark",
105
+ name,
106
+ def: vocab.resolve(name)
107
+ });
108
+ }
109
+ /**
110
+ * Content in bold: markdown `**…**`, bold in `ansi`, and the content as is in plain and `githubLog`.
111
+ *
112
+ * @param content - any number of strings, inlines or arrays of them, in order
113
+ */
114
+ static strong(...content) {
115
+ return freeze({
116
+ _tag: "Strong",
117
+ content: inlines(content.flatMap((part) => isList(part) ? part : [part]))
118
+ });
119
+ }
120
+ /**
121
+ * Content in italic: markdown `*…*` (which GFM reads inside a word too), italic in `ansi`, and the content as is in
122
+ * plain and `githubLog`.
123
+ *
124
+ * @param content - any number of strings, inlines or arrays of them, in order
125
+ */
126
+ static em(...content) {
127
+ return freeze({
128
+ _tag: "Emphasis",
129
+ content: inlines(content.flatMap((part) => isList(part) ? part : [part]))
130
+ });
131
+ }
132
+ /**
133
+ * A file path, shown through the context's `displayPath` and never linked.
134
+ *
135
+ * @param path - the path, usually absolute
136
+ */
137
+ static file(path) {
138
+ return freeze({
139
+ _tag: "File",
140
+ path
141
+ });
142
+ }
143
+ /**
144
+ * A path or breadcrumb; a renderer joins the segments with the audience's separator.
145
+ *
146
+ * @param segments - the segments, in order
147
+ */
148
+ static path(...segments) {
149
+ return freeze({
150
+ _tag: "Path",
151
+ segments: frozenArray(segments)
152
+ });
153
+ }
154
+ /**
155
+ * A heading.
156
+ *
157
+ * @param level - 1 to 4
158
+ * @param content - the heading text
159
+ */
160
+ static heading(level, content) {
161
+ return freeze({
162
+ _tag: "Heading",
163
+ level,
164
+ content: inlines(content)
165
+ });
166
+ }
167
+ /**
168
+ * One logical line of content.
169
+ *
170
+ * @param content - any number of strings, inlines or arrays of them, in order
171
+ */
172
+ static paragraph(...content) {
173
+ return freeze({
174
+ _tag: "Paragraph",
175
+ content: inlines(content.flatMap((part) => isList(part) ? part : [part]))
176
+ });
177
+ }
178
+ /**
179
+ * A list of blocks.
180
+ *
181
+ * @param items - the items
182
+ * @param options - `cap`, `overflow`, and `compact` for no blank lines inside an item
183
+ */
184
+ static list(items, options) {
185
+ return freeze({
186
+ _tag: "List",
187
+ items: frozenArray(items),
188
+ ...overflowFields(options),
189
+ ...options?.compact === void 0 ? {} : { compact: options.compact }
190
+ });
191
+ }
192
+ /**
193
+ * A table.
194
+ *
195
+ * @param columns - the columns: a header and an optional alignment each
196
+ * @param rows - the rows; each cell takes a string, an inline or an array of either
197
+ * @param options - `cap`, `overflow`, and `style: "pipe"` for istanbul's shape in plain and `ansi`
198
+ */
199
+ static table(columns, rows, options) {
200
+ return freeze({
201
+ _tag: "Table",
202
+ columns: frozenArray(columns.map((column) => freeze({
203
+ header: inlines(column.header),
204
+ ...column.align === void 0 ? {} : { align: column.align }
205
+ }))),
206
+ rows: frozenArray(rows.map((row) => frozenArray(row.map(inlines)))),
207
+ ...overflowFields(options),
208
+ ...options?.style === void 0 ? {} : { style: options.style }
209
+ });
210
+ }
211
+ /**
212
+ * A tree of labels.
213
+ *
214
+ * @param root - the root; a node's `children` may be left out
215
+ */
216
+ static tree(root) {
217
+ return freeze({
218
+ _tag: "Tree",
219
+ root: treeNode(root)
220
+ });
221
+ }
222
+ /**
223
+ * A titled body a renderer may fold.
224
+ *
225
+ * @param title - the title
226
+ * @param body - the body
227
+ * @param options - `open` asks for it to start unfolded
228
+ */
229
+ static collapsible(title, body, options) {
230
+ return freeze({
231
+ _tag: "Collapsible",
232
+ title: inlines(title),
233
+ body: frozenArray(body),
234
+ ...options?.open === void 0 ? {} : { open: options.open }
235
+ });
236
+ }
237
+ /**
238
+ * A callout.
239
+ *
240
+ * @param kind - `note`, `tip`, `important`, `warning` or `caution`
241
+ * @param body - the body
242
+ */
243
+ static callout(kind, body) {
244
+ return freeze({
245
+ _tag: "Callout",
246
+ kind,
247
+ body: frozenArray(body)
248
+ });
249
+ }
250
+ /**
251
+ * Preformatted text.
252
+ *
253
+ * @param text - the text
254
+ * @param lang - its language, for a renderer that fences it
255
+ */
256
+ static codeBlock(text, lang) {
257
+ return freeze({
258
+ _tag: "CodeBlock",
259
+ ...lang === void 0 ? {} : { lang },
260
+ text
261
+ });
262
+ }
263
+ /**
264
+ * Expected against received text.
265
+ *
266
+ * @param expected - the expected text
267
+ * @param received - the received text
268
+ * @param options - `cap` limits the lines shown
269
+ */
270
+ static diff(expected, received, options) {
271
+ return freeze({
272
+ _tag: "Diff",
273
+ expected,
274
+ received,
275
+ ...options?.cap === void 0 ? {} : { cap: options.cap }
276
+ });
277
+ }
278
+ /**
279
+ * Children under an optional title.
280
+ *
281
+ * @param title - the title, or `undefined` for none
282
+ * @param children - the blocks
283
+ */
284
+ static section(title, children) {
285
+ return freeze({
286
+ _tag: "Section",
287
+ ...title === void 0 ? {} : { title: inlines(title) },
288
+ children: frozenArray(children)
289
+ });
290
+ }
291
+ /**
292
+ * One counter of a `Counts` block, with its status definition resolved.
293
+ *
294
+ * @remarks
295
+ * A name the vocabulary does not have is a compile error.
296
+ *
297
+ * @param vocab - the vocabulary the status belongs to
298
+ * @param name - a status name in it
299
+ * @param options - the counter's `key`, `label` and count `n`, and `showZero` to keep it when `n` is zero
300
+ */
301
+ static counter(vocab, name, options) {
302
+ return counterOf({
303
+ key: options.key,
304
+ label: options.label,
305
+ n: options.n,
306
+ status: {
307
+ name,
308
+ def: vocab.resolve(name)
309
+ },
310
+ ...options.showZero === void 0 ? {} : { showZero: options.showZero }
311
+ });
312
+ }
313
+ /**
314
+ * Counters in one of three layouts.
315
+ *
316
+ * @param options - the counters, the layout and the optional label, total rule, qualifier and duration
317
+ */
318
+ static counts(options) {
319
+ return freeze({
320
+ _tag: "Counts",
321
+ ...options.label === void 0 ? {} : { label: inlines(options.label) },
322
+ counters: frozenArray(options.counters.map(counterOf)),
323
+ ...options.total === void 0 ? {} : { total: options.total },
324
+ ...options.qualifier === void 0 ? {} : { qualifier: inlines(options.qualifier) },
325
+ ...options.durationMs === void 0 ? {} : { durationMs: options.durationMs },
326
+ layout: options.layout,
327
+ ...options.share === void 0 ? {} : { share: options.share },
328
+ ...options.paint === void 0 ? {} : { paint: options.paint },
329
+ ...options.suffix === void 0 ? {} : { suffix: inlines(options.suffix) }
330
+ });
331
+ }
332
+ /**
333
+ * Counters as a table: a row per entry, a column per counter key (in the order the keys first appear, headed by
334
+ * the counter's label), and an optional total row summing each column.
335
+ *
336
+ * @remarks
337
+ * A row without a counter for some key leaves that cell empty, and it counts as zero in the total. A counter whose
338
+ * `n` is zero shows `0`, as a `Doc.table` cell would: a counter's `showZero` has no effect in a table, only in a
339
+ * `Counts` block, so there is no need to set it. `totalRow`
340
+ * labels the total row with a plain `Total` when `true`, or with the content given: for a bold one, pass
341
+ * `totalRow: Doc.strong("Total")`. A column is headed by its counter's `label`; a counter's status paints its cells
342
+ * in `ansi` and is ignored in markdown, so a plain numbers table may pass any status. `labelHeader` heads the label column, which
343
+ * is otherwise empty. When some row has a `durationMs`, a last column shows it with `Fmt.duration`, headed
344
+ * `durationHeader` (`duration` by default); a row without one has an empty cell there and counts as zero in the
345
+ * total row's summed duration.
346
+ *
347
+ * @param rows - each row's label, counters and optional duration
348
+ * @param options - `totalRow`, to add the summed row; the label and duration column headers
349
+ */
350
+ static countsTable(rows, options) {
351
+ const totalRow = options?.totalRow;
352
+ return freeze({
353
+ _tag: "CountsTable",
354
+ rows: frozenArray(rows.map((row) => freeze({
355
+ label: inlines(row.label),
356
+ counters: frozenArray(row.counters.map(counterOf)),
357
+ ...row.durationMs === void 0 ? {} : { durationMs: row.durationMs }
358
+ }))),
359
+ ...totalRow === void 0 ? {} : { totalRow: typeof totalRow === "boolean" ? totalRow : inlines(totalRow) },
360
+ ...options?.labelHeader === void 0 ? {} : { labelHeader: inlines(options.labelHeader) },
361
+ ...options?.durationHeader === void 0 ? {} : { durationHeader: inlines(options.durationHeader) }
362
+ });
363
+ }
364
+ /**
365
+ * Lines, one per entry, in every renderer: markdown joins them with hard breaks so they never collapse into one.
366
+ *
367
+ * @param lines - the entries; each takes a string, an inline or an array of either
368
+ */
369
+ static lines(lines) {
370
+ return freeze({
371
+ _tag: "Lines",
372
+ lines: frozenArray(lines.map(inlines))
373
+ });
374
+ }
375
+ /**
376
+ * One line of content; with `truncate`, it is cut to the width with the glyph set's ellipsis instead of wrapping.
377
+ *
378
+ * @remarks
379
+ * Without `truncate` a line longer than the width wraps. For a single line that must never wrap nor be cut, such
380
+ * as a test's full name used as a title, use {@link Doc.verbatim}.
381
+ *
382
+ * @param content - the line
383
+ * @param options - `truncate`
384
+ */
385
+ static line(content, options) {
386
+ return freeze({
387
+ _tag: "Line",
388
+ content: inlines(content),
389
+ ...options?.truncate === void 0 ? {} : { truncate: options.truncate }
390
+ });
391
+ }
392
+ /**
393
+ * A unified diff as given, such as a test runner's: sanitized, its `+` and `-` lines painted `success` and
394
+ * `failure` in `ansi`, and a `diff` fence in markdown.
395
+ *
396
+ * @remarks
397
+ * With `truncate`, plain and `ansi` cut each line to the width with the glyph set's ellipsis instead of wrapping
398
+ * it; an agent's or a CI's width is unbounded, so nothing is cut for them unless the context gives a finite width.
399
+ * Markdown keeps every line whole. Inside a compact list item a blank line of the diff keeps the item's indent.
400
+ *
401
+ * A trailing line break ends the last line, as in a unified diff file, and adds no blank line after it: `"a\n"` is
402
+ * one line. To end on a blank line, end the text with two line breaks.
403
+ *
404
+ * @param unified - the diff
405
+ * @param options - `cap`, the most lines shown; `truncate`, to cut each line to the width
406
+ */
407
+ static diffText(unified, options) {
408
+ return freeze({
409
+ _tag: "DiffText",
410
+ text: unified,
411
+ ...options?.cap === void 0 ? {} : { cap: options.cap },
412
+ ...options?.truncate === void 0 ? {} : { truncate: options.truncate }
413
+ });
414
+ }
415
+ /**
416
+ * Lines kept exactly: each indented by `indent` spaces, sanitized, and never wrapped.
417
+ *
418
+ * @remarks
419
+ * Plain, `ansi` and `githubLog` write the lines as they are; markdown fences them, so the indentation survives.
420
+ *
421
+ * It is the tool for a single line that must never wrap nor be cut, whatever the width: {@link Doc.line} wraps at
422
+ * the width, or cuts with `truncate`, and `verbatim` does neither.
423
+ *
424
+ * @param text - the lines
425
+ * @param options - `indent`, the spaces in front of every line; none by default
426
+ */
427
+ static verbatim(text, options) {
428
+ return freeze({
429
+ _tag: "Verbatim",
430
+ text,
431
+ ...options?.indent === void 0 ? {} : { indent: options.indent }
432
+ });
433
+ }
434
+ /**
435
+ * A GitHub Actions annotation: `Render.githubLog` writes it as one workflow command (`::error file=…::message`),
436
+ * and every other renderer writes nothing.
437
+ *
438
+ * @remarks
439
+ * It is the kit's own command, so `githubLog` does not neutralize it; its message and properties are escaped, so
440
+ * no text in them can end the command or start another. It is a command where a line starts: at the top level, as a
441
+ * top-level section's child, or as a direct child of a group's body. Nested deeper, it is dropped.
442
+ *
443
+ * @param options - the level, and the optional file, position and title
444
+ * @param message - what it says
445
+ */
446
+ static annotation(options, message) {
447
+ return freeze({
448
+ _tag: "Annotation",
449
+ level: options.level,
450
+ ...options.file === void 0 ? {} : { file: options.file },
451
+ ...options.line === void 0 ? {} : { line: options.line },
452
+ ...options.col === void 0 ? {} : { col: options.col },
453
+ ...options.endLine === void 0 ? {} : { endLine: options.endLine },
454
+ ...options.endColumn === void 0 ? {} : { endColumn: options.endColumn },
455
+ ...options.title === void 0 ? {} : { title: options.title },
456
+ message
457
+ });
458
+ }
459
+ /**
460
+ * The total of a `Counts` block: the caller's rule when it has one, otherwise the sum of `n` over every counter.
461
+ *
462
+ * @remarks
463
+ * The rule sees every counter, including the ones a renderer hides, so hiding never changes the total.
464
+ *
465
+ * @param block - the `Counts` block
466
+ */
467
+ static total(block) {
468
+ return totalOf(block);
469
+ }
470
+ /**
471
+ * The counters a renderer shows: every one except a zero counter that does not ask for `showZero`.
472
+ *
473
+ * @param block - the `Counts` block
474
+ */
475
+ static visibleCounters(block) {
476
+ return visibleCountersOf(block);
477
+ }
478
+ /**
479
+ * Render a document for whoever is running the program and write it to a stream.
480
+ *
481
+ * @remarks
482
+ * The context is {@link Render.context} for the stream, so the width, the colour, the links and the audience
483
+ * come from the services the program already has, and the text is written with `Console.log` or
484
+ * `Console.error`: a test captures it by swapping the `Console`. With `format: "auto"` the renderer follows
485
+ * the audience, and the width is unbounded for an agent and a CI.
486
+ *
487
+ * An agent is never written an escape of any kind, even with an explicit `format: "ansi"`: its context is
488
+ * colourless and its links are off. A document that renders to nothing prints nothing.
489
+ *
490
+ * `CurrentRuntimeEnv` is read if the environment has one and is not required: a `ci` audience prints
491
+ * GitHub's log format only when it says GitHub Actions, and plain text otherwise, including when it is
492
+ * absent. An explicit `format` is honoured whatever the audience.
493
+ *
494
+ * @param doc - the document
495
+ * @param options - the stream and the format
496
+ */
497
+ static print = (doc, options) => Effect.gen(function* () {
498
+ const stream = options?.stream ?? "stdout";
499
+ const ctx = yield* Render.context(stream, {
500
+ ...options?.displayPath === void 0 ? {} : { displayPath: options.displayPath },
501
+ ...options?.width === void 0 ? {} : { width: options.width }
502
+ });
503
+ const requested = options?.format ?? "auto";
504
+ const format = requested === "auto" ? yield* autoFormat(ctx.audience) : requested;
505
+ const text = Render[format](doc, ctx);
506
+ if (text === "") return;
507
+ yield* stream === "stderr" ? Console.error(text) : Console.log(text);
508
+ });
509
+ };
510
+
511
+ //#endregion
512
+ export { Doc };
package/Fmt.js ADDED
@@ -0,0 +1,133 @@
1
+ import { displayWidth, graphemes, stripAnsi } from "./internal/displayWidth.js";
2
+
3
+ //#region src/Fmt.ts
4
+ const CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
5
+ /**
6
+ * Text made safe to lay out and print: complete ANSI and OSC sequences removed, every remaining control character
7
+ * removed (a stray ESC, BEL, BS, DEL, the C1 range) except line feed and carriage return, and a tab turned into a
8
+ * space.
9
+ *
10
+ * @remarks
11
+ * Stripping complete sequences is not enough on its own: a lone ESC survives it, and so does whatever would
12
+ * complete a sequence once two adjacent pieces of text are joined (`ESC` in one node, `[31m` in the next). With
13
+ * every ESC and C0 or C1 control gone after the strip, nothing can reassemble, so the width of the text equals what
14
+ * a terminal shows. A tab counts as no columns but draws up to eight, hence the space.
15
+ *
16
+ * @internal
17
+ */
18
+ const sanitize = (input) => stripAnsi(input).replace(/\t/g, " ").replace(CONTROL, "");
19
+ /**
20
+ * Small, pure formatting primitives for terminal output.
21
+ *
22
+ * @public
23
+ */
24
+ var Fmt = class {
25
+ constructor() {}
26
+ /**
27
+ * Text made safe to lay out and print, exactly as the renderers make it: complete ANSI and OSC sequences removed,
28
+ * every other control character removed (a stray ESC, BEL, BS, DEL, the C1 range) except line feed and carriage
29
+ * return, and a tab turned into a space.
30
+ *
31
+ * @remarks
32
+ * For a `render` or any other text a program builds by hand from data it does not control: an error message, a
33
+ * file name, a value from the environment. Line breaks are kept, so split on them if the text must be one line.
34
+ *
35
+ * @param text - the text
36
+ */
37
+ static sanitize = sanitize;
38
+ /**
39
+ * The display width of `text` in terminal columns: wide East Asian characters and emoji count two,
40
+ * combining marks and ANSI escapes count none.
41
+ *
42
+ * @param text - the text to measure
43
+ */
44
+ static width = (text) => displayWidth(text);
45
+ /**
46
+ * Cut `text` to at most `width` columns, ending in `ellipsis` when it was cut.
47
+ *
48
+ * @remarks
49
+ * Grapheme-safe: a grapheme, such as a wide character or an emoji ZWJ family, is kept whole or dropped
50
+ * whole, so the result is never wider than `width`. Text that already fits is returned unchanged, colour
51
+ * escapes included. Text that must be cut is cut as plain text: its ANSI escapes are dropped rather than cut
52
+ * in half. When the ellipsis cannot fit, it is omitted and the text is cut to `width`; a `width` of 0 or
53
+ * less gives `""`.
54
+ *
55
+ * @param text - the text to truncate
56
+ * @param width - the most columns the result may take
57
+ * @param options - the ellipsis
58
+ */
59
+ static truncate = (text, width, options) => {
60
+ const limit = Number.isFinite(width) ? Math.floor(width) : width === Number.POSITIVE_INFINITY ? Infinity : 0;
61
+ if (limit <= 0) return "";
62
+ if (displayWidth(text) <= limit) return text;
63
+ const plain = stripAnsi(text);
64
+ const ellipsis = options?.ellipsis ?? "…";
65
+ const ellipsisWidth = displayWidth(ellipsis);
66
+ const useEllipsis = ellipsisWidth <= limit && ellipsis !== "";
67
+ const budget = useEllipsis ? limit - ellipsisWidth : limit;
68
+ let out = "";
69
+ let used = 0;
70
+ for (const grapheme of graphemes(plain)) {
71
+ const w = displayWidth(grapheme);
72
+ if (used + w > budget) break;
73
+ out += grapheme;
74
+ used += w;
75
+ }
76
+ return useEllipsis ? `${out}${ellipsis}` : out;
77
+ };
78
+ /**
79
+ * A duration in milliseconds as short human text: `250ms`, `1.2s`, `2s`, `1m 3s`, `2m`, `1h 2m`, `2h`.
80
+ *
81
+ * @remarks
82
+ * Under a second it is whole milliseconds; under a minute, seconds to one decimal with a trailing `.0`
83
+ * dropped; under an hour, minutes and whole seconds, with the seconds dropped when zero; from an hour, hours and
84
+ * whole minutes, the seconds dropped. A value that rounds up to the next unit (`999.6` to a second, `59999` to a
85
+ * minute, `3599999` to an hour) is written in that unit, so `1000ms`, `60s` and `60m` never appear. There is no
86
+ * days unit. A negative or non-finite input is `0ms`.
87
+ *
88
+ * @param ms - the duration in milliseconds
89
+ */
90
+ static duration = (ms) => {
91
+ const value = Number.isFinite(ms) ? Math.max(0, ms) : 0;
92
+ const whole = Math.round(value);
93
+ if (whole < 1e3) return `${whole}ms`;
94
+ const tenths = Math.round(value / 100);
95
+ if (tenths < 600) return `${Number((tenths / 10).toFixed(1))}s`;
96
+ const seconds = Math.round(value / 1e3);
97
+ if (seconds >= 3600) {
98
+ const hours = Math.floor(seconds / 3600);
99
+ const remaining = Math.floor(seconds % 3600 / 60);
100
+ return remaining === 0 ? `${hours}h` : `${hours}h ${remaining}m`;
101
+ }
102
+ const minutes = Math.floor(seconds / 60);
103
+ const rest = seconds % 60;
104
+ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
105
+ };
106
+ /**
107
+ * A ratio from 0 to 1 as a percentage, or a number already from 0 to 100 with `scale: 100`: `83.3%`, `50%`,
108
+ * `100%`.
109
+ *
110
+ * @remarks
111
+ * Rounded to `digits` decimal places (one by default) with trailing zeros dropped, so whole values print
112
+ * without a decimal. A value that rounds to zero prints `0%`, never `-0%`. The input is not validated.
113
+ *
114
+ * @param n - the ratio, 0 to 1 (0 to 100 with `scale: 100`)
115
+ * @param options - the decimal places and the scale
116
+ */
117
+ static percent = (n, options) => {
118
+ const digits = Math.min(10, Math.max(0, Math.floor(options?.digits ?? 1)));
119
+ const percent = options?.scale === 100 ? n : n * 100;
120
+ return `${Number(percent.toFixed(digits))}%`;
121
+ };
122
+ /**
123
+ * A count with its noun: `1 test`, `2 tests`, `0 tests`.
124
+ *
125
+ * @param n - the count
126
+ * @param singular - the noun for exactly one
127
+ * @param plural - the noun otherwise; `singular` with an `s` appended by default
128
+ */
129
+ static plural = (n, singular, plural) => `${n} ${n === 1 ? singular : plural ?? `${singular}s`}`;
130
+ };
131
+
132
+ //#endregion
133
+ export { Fmt, sanitize };
@@ -0,0 +1,40 @@
1
+ import { WorkflowCommand } from "@effected/github-commands";
2
+
3
+ //#region src/GithubAnnotation.ts
4
+ /**
5
+ * GitHub Actions annotations, as workflow commands.
6
+ *
7
+ * @public
8
+ */
9
+ var GithubAnnotation = class {
10
+ constructor() {}
11
+ /**
12
+ * Format an annotation as a workflow command: `::error title=T,file=F,line=1,endLine=2,col=3,endColumn=4::message`.
13
+ *
14
+ * @remarks
15
+ * The message escapes `%`, CR and LF; a property value escapes those and `:` and `,`, per GitHub's
16
+ * [workflow-command documentation](https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions).
17
+ * The percent sign is escaped first, so an escape that was just written is never escaped again. An unescaped line
18
+ * break in a message would let the text after it be read as a new command, which is why the escaping is not optional.
19
+ *
20
+ * A property that is not given is left out, and the properties are written in the order `title`, `file`, `line`,
21
+ * `endLine`, `col`, `endColumn`, the same as `@effected/github-commands`' `WorkflowCommand`, which this renders
22
+ * through.
23
+ *
24
+ * @param annotation - the level and the optional file, position and title
25
+ * @param message - the annotation's text
26
+ */
27
+ static format = (annotation, message) => {
28
+ return WorkflowCommand.render(annotation.level, {
29
+ title: annotation.title,
30
+ file: annotation.file,
31
+ line: annotation.line,
32
+ endLine: annotation.endLine,
33
+ col: annotation.col,
34
+ endColumn: annotation.endColumn
35
+ }, message);
36
+ };
37
+ };
38
+
39
+ //#endregion
40
+ export { GithubAnnotation };