@effected/cli 0.10.0 → 0.12.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/Cancelled.js +44 -0
- package/CliAudience.js +178 -0
- package/CliColor.js +13 -19
- package/CliEnv.js +89 -0
- package/CliExit.js +1 -1
- package/CliFailure.js +302 -0
- package/CliInteractive.js +71 -0
- package/CliLinks.js +154 -0
- package/CliLog.js +346 -0
- package/CliLogger.js +34 -33
- package/CliMessage.js +80 -0
- package/CliPrompt.js +104 -0
- package/CliRuntime.js +110 -54
- package/CliTest.js +16 -0
- package/CliTheme.js +141 -0
- package/ConfigIssueRenderer.js +14 -33
- package/Doc.js +536 -0
- package/Fmt.js +133 -0
- package/GithubAnnotation.js +40 -0
- package/Glyphs.js +83 -0
- package/NotInteractive.js +42 -0
- package/README.md +145 -131
- package/Render.js +255 -0
- package/SchemaIssueRenderer.js +7 -10
- package/Status.js +166 -0
- package/TestTerminal.js +80 -0
- package/Token.js +69 -0
- package/index.d.ts +3089 -169
- package/index.js +19 -1
- package/internal/ansi.js +230 -0
- package/internal/autoFormat.js +34 -0
- package/internal/canPrompt.js +15 -0
- package/internal/counts.js +84 -0
- package/internal/diagnostics.js +32 -0
- package/internal/displayWidth.js +35 -0
- package/internal/failureTarget.js +195 -0
- package/internal/fallbackAnswer.js +18 -0
- package/internal/fileSink.js +62 -0
- package/internal/format.js +62 -7
- package/internal/layout.js +250 -0
- package/internal/linkScheme.js +30 -0
- package/internal/linkTarget.js +50 -0
- package/internal/logSafety.js +46 -0
- package/internal/renderAnsi.js +52 -0
- package/internal/renderDoc.js +320 -0
- package/internal/renderGithubLog.js +46 -0
- package/internal/renderMarkdown.js +368 -0
- package/internal/renderPlain.js +50 -0
- package/internal/scanAudience.js +106 -0
- package/internal/splitFrame.js +56 -0
- package/internal/wizardGate.js +18 -0
- package/package.json +40 -5
- package/testing.d.ts +88 -2
- package/testing.js +2 -1
- package/ui/CliUi.js +432 -0
- package/ui/CliUiLive.js +446 -0
- package/ui/Confirm.js +245 -0
- package/ui/DocView.js +74 -0
- package/ui/KeyHelp.js +62 -0
- package/ui/KeyTable.js +199 -0
- package/ui/MultiSelect.js +260 -0
- package/ui/Select.js +230 -0
- package/ui/Tabs.js +202 -0
- package/ui/TextInput.js +290 -0
- package/ui/Toggle.js +32 -0
- package/ui/UiKey.js +44 -0
- package/ui/UiProvider.js +60 -0
- package/ui/UiStreams.js +18 -0
- package/ui/UiTheme.js +119 -0
- package/ui/Viewport.js +204 -0
- package/ui/internal/ErrorBoundary.js +30 -0
- package/ui/internal/Holder.js +74 -0
- package/ui/internal/ScreenContext.js +52 -0
- package/ui/internal/UiProviders.js +21 -0
- package/ui/internal/ink.js +122 -0
- package/ui/internal/inkChalk.js +58 -0
- package/ui/internal/inkConsole.js +146 -0
- package/ui/internal/lazyView.js +74 -0
- package/ui/internal/lineText.js +19 -0
- package/ui/internal/mountPermit.js +16 -0
- package/ui/internal/perfDrain.js +33 -0
- package/ui/internal/processStreams.js +19 -0
- package/ui/internal/renderOptions.js +13 -0
- package/ui/testing/CliUiTest.js +760 -0
- package/ui/testing/fakeStreams.js +79 -0
- package/ui/testing/terminalModel.js +59 -0
- package/ui-testing-serializer.d.ts +14 -0
- package/ui-testing-serializer.js +33 -0
- package/ui-testing.d.ts +527 -0
- package/ui-testing.js +3 -0
- package/ui.d.ts +1790 -0
- package/ui.js +17 -0
package/Doc.js
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
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
|
+
label: typeof counter.label === "string" ? counter.label : freeze({
|
|
29
|
+
one: counter.label.one,
|
|
30
|
+
other: counter.label.other
|
|
31
|
+
}),
|
|
32
|
+
status: freeze({
|
|
33
|
+
name: counter.status.name,
|
|
34
|
+
def: freeze({ ...counter.status.def })
|
|
35
|
+
})
|
|
36
|
+
});
|
|
37
|
+
/**
|
|
38
|
+
* Constructors for the document IR, and two helpers a renderer shares.
|
|
39
|
+
*
|
|
40
|
+
* @remarks
|
|
41
|
+
* Every constructor returns a frozen node and copies the arrays it is given, so editing an input afterwards
|
|
42
|
+
* cannot change a document. An optional field that is not given is absent from the node, not `undefined`.
|
|
43
|
+
* Content arguments accept a string, an `Inline` or an array of either.
|
|
44
|
+
*
|
|
45
|
+
* A node is plain data: nothing decodes or encodes one, so a function field such as `overflow` or `total` is fine
|
|
46
|
+
* and a document is not meant to be serialised.
|
|
47
|
+
*
|
|
48
|
+
* Freezing covers what a `Doc` constructor builds. A literal you write by hand is not frozen, and a `Style` object
|
|
49
|
+
* given as a token is shared by reference (the freeze of a status definition is shallow for the same reason).
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* import { Doc, Status } from "@effected/cli"
|
|
54
|
+
*
|
|
55
|
+
* const report = [
|
|
56
|
+
* Doc.heading(2, "Results"),
|
|
57
|
+
* Doc.paragraph(Doc.status(Status.core, "success"), " ", "3 checks passed"),
|
|
58
|
+
* Doc.table([{ header: "Check" }, { header: "Time", align: "right" }], [["lint", "1.2s"]]),
|
|
59
|
+
* ]
|
|
60
|
+
* // Written for whoever is reading: `yield* Doc.print(report)`
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* @public
|
|
64
|
+
*/
|
|
65
|
+
var Doc = class {
|
|
66
|
+
constructor() {}
|
|
67
|
+
/**
|
|
68
|
+
* A run of text.
|
|
69
|
+
*
|
|
70
|
+
* @param value - the text
|
|
71
|
+
* @param token - a semantic token or a style to paint it with
|
|
72
|
+
*/
|
|
73
|
+
static text(value, token) {
|
|
74
|
+
return text(value, token);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Code in a monospace span.
|
|
78
|
+
*
|
|
79
|
+
* @param value - the code
|
|
80
|
+
*/
|
|
81
|
+
static code(value) {
|
|
82
|
+
return freeze({
|
|
83
|
+
_tag: "Code",
|
|
84
|
+
value
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
static link(target, label, options) {
|
|
88
|
+
if (target === void 0) return typeof label === "string" ? text(label) : label;
|
|
89
|
+
const fallback = "url" in target ? target.url : target.file;
|
|
90
|
+
return freeze({
|
|
91
|
+
_tag: "Link",
|
|
92
|
+
target: freeze({ ...target }),
|
|
93
|
+
label: inlines(label ?? fallback),
|
|
94
|
+
...options?.suffix === void 0 ? {} : { suffix: options.suffix }
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A status glyph, holding the resolved definition.
|
|
99
|
+
*
|
|
100
|
+
* @remarks
|
|
101
|
+
* A name the vocabulary does not have is a compile error.
|
|
102
|
+
*
|
|
103
|
+
* @param vocab - the vocabulary the name belongs to
|
|
104
|
+
* @param name - a status name in it
|
|
105
|
+
*/
|
|
106
|
+
static status(vocab, name) {
|
|
107
|
+
return freeze({
|
|
108
|
+
_tag: "StatusMark",
|
|
109
|
+
name,
|
|
110
|
+
def: vocab.resolve(name)
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Content in bold: markdown `**…**`, bold in `ansi`, and the content as is in plain and `githubLog`.
|
|
115
|
+
*
|
|
116
|
+
* @param content - any number of strings, inlines or arrays of them, in order
|
|
117
|
+
*/
|
|
118
|
+
static strong(...content) {
|
|
119
|
+
return freeze({
|
|
120
|
+
_tag: "Strong",
|
|
121
|
+
content: inlines(content.flatMap((part) => isList(part) ? part : [part]))
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Content in italic: markdown `*…*` (which GFM reads inside a word too), italic in `ansi`, and the content as is in
|
|
126
|
+
* plain and `githubLog`.
|
|
127
|
+
*
|
|
128
|
+
* @param content - any number of strings, inlines or arrays of them, in order
|
|
129
|
+
*/
|
|
130
|
+
static em(...content) {
|
|
131
|
+
return freeze({
|
|
132
|
+
_tag: "Emphasis",
|
|
133
|
+
content: inlines(content.flatMap((part) => isList(part) ? part : [part]))
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* A file path, shown through the context's `displayPath` and never linked.
|
|
138
|
+
*
|
|
139
|
+
* @param path - the path, usually absolute
|
|
140
|
+
*/
|
|
141
|
+
static file(path) {
|
|
142
|
+
return freeze({
|
|
143
|
+
_tag: "File",
|
|
144
|
+
path
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* A path or breadcrumb; a renderer joins the segments with the audience's separator.
|
|
149
|
+
*
|
|
150
|
+
* @param segments - the segments, in order
|
|
151
|
+
*/
|
|
152
|
+
static path(...segments) {
|
|
153
|
+
return freeze({
|
|
154
|
+
_tag: "Path",
|
|
155
|
+
segments: frozenArray(segments)
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* A heading.
|
|
160
|
+
*
|
|
161
|
+
* @param level - 1 to 4
|
|
162
|
+
* @param content - the heading text
|
|
163
|
+
*/
|
|
164
|
+
static heading(level, content) {
|
|
165
|
+
return freeze({
|
|
166
|
+
_tag: "Heading",
|
|
167
|
+
level,
|
|
168
|
+
content: inlines(content)
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* One logical line of content.
|
|
173
|
+
*
|
|
174
|
+
* @param content - any number of strings, inlines or arrays of them, in order
|
|
175
|
+
*/
|
|
176
|
+
static paragraph(...content) {
|
|
177
|
+
return freeze({
|
|
178
|
+
_tag: "Paragraph",
|
|
179
|
+
content: inlines(content.flatMap((part) => isList(part) ? part : [part]))
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* A list of blocks.
|
|
184
|
+
*
|
|
185
|
+
* @param items - the items
|
|
186
|
+
* @param options - `cap`, `overflow`, and `compact` for no blank lines inside an item
|
|
187
|
+
*/
|
|
188
|
+
static list(items, options) {
|
|
189
|
+
return freeze({
|
|
190
|
+
_tag: "List",
|
|
191
|
+
items: frozenArray(items),
|
|
192
|
+
...overflowFields(options),
|
|
193
|
+
...options?.compact === void 0 ? {} : { compact: options.compact }
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* A table.
|
|
198
|
+
*
|
|
199
|
+
* @param columns - the columns: a header and an optional alignment each
|
|
200
|
+
* @param rows - the rows; each cell takes a string, an inline or an array of either
|
|
201
|
+
* @param options - `cap`, `overflow`, and `style: "pipe"` for istanbul's shape in plain and `ansi`
|
|
202
|
+
*/
|
|
203
|
+
static table(columns, rows, options) {
|
|
204
|
+
return freeze({
|
|
205
|
+
_tag: "Table",
|
|
206
|
+
columns: frozenArray(columns.map((column) => freeze({
|
|
207
|
+
header: inlines(column.header),
|
|
208
|
+
...column.align === void 0 ? {} : { align: column.align }
|
|
209
|
+
}))),
|
|
210
|
+
rows: frozenArray(rows.map((row) => frozenArray(row.map(inlines)))),
|
|
211
|
+
...overflowFields(options),
|
|
212
|
+
...options?.style === void 0 ? {} : { style: options.style }
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* A tree of labels.
|
|
217
|
+
*
|
|
218
|
+
* @param root - the root; a node's `children` may be left out
|
|
219
|
+
*/
|
|
220
|
+
static tree(root) {
|
|
221
|
+
return freeze({
|
|
222
|
+
_tag: "Tree",
|
|
223
|
+
root: treeNode(root)
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* A titled body a renderer may fold.
|
|
228
|
+
*
|
|
229
|
+
* @param title - the title
|
|
230
|
+
* @param body - the body
|
|
231
|
+
* @param options - `open` asks for it to start unfolded
|
|
232
|
+
*/
|
|
233
|
+
static collapsible(title, body, options) {
|
|
234
|
+
return freeze({
|
|
235
|
+
_tag: "Collapsible",
|
|
236
|
+
title: inlines(title),
|
|
237
|
+
body: frozenArray(body),
|
|
238
|
+
...options?.open === void 0 ? {} : { open: options.open }
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* A callout.
|
|
243
|
+
*
|
|
244
|
+
* @param kind - `note`, `tip`, `important`, `warning` or `caution`
|
|
245
|
+
* @param body - the body
|
|
246
|
+
*/
|
|
247
|
+
static callout(kind, body) {
|
|
248
|
+
return freeze({
|
|
249
|
+
_tag: "Callout",
|
|
250
|
+
kind,
|
|
251
|
+
body: frozenArray(body)
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Preformatted text.
|
|
256
|
+
*
|
|
257
|
+
* @param text - the text
|
|
258
|
+
* @param lang - its language, for a renderer that fences it
|
|
259
|
+
*/
|
|
260
|
+
static codeBlock(text, lang) {
|
|
261
|
+
return freeze({
|
|
262
|
+
_tag: "CodeBlock",
|
|
263
|
+
...lang === void 0 ? {} : { lang },
|
|
264
|
+
text
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Expected against received text.
|
|
269
|
+
*
|
|
270
|
+
* @param expected - the expected text
|
|
271
|
+
* @param received - the received text
|
|
272
|
+
* @param options - `cap` limits the lines shown
|
|
273
|
+
*/
|
|
274
|
+
static diff(expected, received, options) {
|
|
275
|
+
return freeze({
|
|
276
|
+
_tag: "Diff",
|
|
277
|
+
expected,
|
|
278
|
+
received,
|
|
279
|
+
...options?.cap === void 0 ? {} : { cap: options.cap }
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Children under an optional title.
|
|
284
|
+
*
|
|
285
|
+
* @remarks
|
|
286
|
+
* The children are separated by blank lines (unless the document is compact); a title sits directly above the first.
|
|
287
|
+
* `Doc.section(undefined, blocks)` is the way to space a document's top-level blocks, which are otherwise joined with
|
|
288
|
+
* no blank line.
|
|
289
|
+
*
|
|
290
|
+
* @param title - the title, or `undefined` for none
|
|
291
|
+
* @param children - the blocks
|
|
292
|
+
*/
|
|
293
|
+
static section(title, children) {
|
|
294
|
+
return freeze({
|
|
295
|
+
_tag: "Section",
|
|
296
|
+
...title === void 0 ? {} : { title: inlines(title) },
|
|
297
|
+
children: frozenArray(children)
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* One counter of a `Counts` block, with its status definition resolved.
|
|
302
|
+
*
|
|
303
|
+
* @remarks
|
|
304
|
+
* A name the vocabulary does not have is a compile error.
|
|
305
|
+
*
|
|
306
|
+
* The label is one string, or `{ one, other }` to pluralise by count: `one` when the count is exactly 1 and `other`
|
|
307
|
+
* for every other count, 0 included. A count standing alone reads by its own `n` (`1 change`, `2 changes`); a
|
|
308
|
+
* headline shown as a share of the total reads by that total, the noun it counts (`1/1 repo`, `1/3 repos`,
|
|
309
|
+
* `2/3 repos`).
|
|
310
|
+
*
|
|
311
|
+
* @param vocab - the vocabulary the status belongs to
|
|
312
|
+
* @param name - a status name in it
|
|
313
|
+
* @param options - the counter's `key`, its `label` (one string, or `{ one, other }`), its count `n`, and `showZero`
|
|
314
|
+
* to keep it when `n` is zero
|
|
315
|
+
*/
|
|
316
|
+
static counter(vocab, name, options) {
|
|
317
|
+
return counterOf({
|
|
318
|
+
key: options.key,
|
|
319
|
+
label: options.label,
|
|
320
|
+
n: options.n,
|
|
321
|
+
status: {
|
|
322
|
+
name,
|
|
323
|
+
def: vocab.resolve(name)
|
|
324
|
+
},
|
|
325
|
+
...options.showZero === void 0 ? {} : { showZero: options.showZero }
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Counters in one of three layouts.
|
|
330
|
+
*
|
|
331
|
+
* @param options - the counters, the layout and the optional label, total rule, qualifier and duration
|
|
332
|
+
*/
|
|
333
|
+
static counts(options) {
|
|
334
|
+
return freeze({
|
|
335
|
+
_tag: "Counts",
|
|
336
|
+
...options.label === void 0 ? {} : { label: inlines(options.label) },
|
|
337
|
+
counters: frozenArray(options.counters.map(counterOf)),
|
|
338
|
+
...options.total === void 0 ? {} : { total: options.total },
|
|
339
|
+
...options.qualifier === void 0 ? {} : { qualifier: inlines(options.qualifier) },
|
|
340
|
+
...options.durationMs === void 0 ? {} : { durationMs: options.durationMs },
|
|
341
|
+
layout: options.layout,
|
|
342
|
+
...options.share === void 0 ? {} : { share: options.share },
|
|
343
|
+
...options.paint === void 0 ? {} : { paint: options.paint },
|
|
344
|
+
...options.suffix === void 0 ? {} : { suffix: inlines(options.suffix) }
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Counters as a table: a row per entry, a column per counter key (in the order the keys first appear, headed by
|
|
349
|
+
* the counter's label), and an optional total row summing each column.
|
|
350
|
+
*
|
|
351
|
+
* @remarks
|
|
352
|
+
* A row without a counter for some key leaves that cell empty, and it counts as zero in the total. A counter whose
|
|
353
|
+
* `n` is zero shows `0`, as a `Doc.table` cell would: a counter's `showZero` has no effect in a table, only in a
|
|
354
|
+
* `Counts` block, so there is no need to set it. `totalRow`
|
|
355
|
+
* labels the total row with a plain `Total` when `true`, or with the content given: for a bold one, pass
|
|
356
|
+
* `totalRow: Doc.strong("Total")`. A column is headed by its counter's `label`; a counter's status paints its cells
|
|
357
|
+
* in `ansi` and is ignored in markdown, so a plain numbers table may pass any status. `labelHeader` heads the label column, which
|
|
358
|
+
* is otherwise empty. When some row has a `durationMs`, a last column shows it with `Fmt.duration`, headed
|
|
359
|
+
* `durationHeader` (`duration` by default); a row without one has an empty cell there and counts as zero in the
|
|
360
|
+
* total row's summed duration.
|
|
361
|
+
*
|
|
362
|
+
* @param rows - each row's label, counters and optional duration
|
|
363
|
+
* @param options - `totalRow`, to add the summed row; the label and duration column headers
|
|
364
|
+
*/
|
|
365
|
+
static countsTable(rows, options) {
|
|
366
|
+
const totalRow = options?.totalRow;
|
|
367
|
+
return freeze({
|
|
368
|
+
_tag: "CountsTable",
|
|
369
|
+
rows: frozenArray(rows.map((row) => freeze({
|
|
370
|
+
label: inlines(row.label),
|
|
371
|
+
counters: frozenArray(row.counters.map(counterOf)),
|
|
372
|
+
...row.durationMs === void 0 ? {} : { durationMs: row.durationMs }
|
|
373
|
+
}))),
|
|
374
|
+
...totalRow === void 0 ? {} : { totalRow: typeof totalRow === "boolean" ? totalRow : inlines(totalRow) },
|
|
375
|
+
...options?.labelHeader === void 0 ? {} : { labelHeader: inlines(options.labelHeader) },
|
|
376
|
+
...options?.durationHeader === void 0 ? {} : { durationHeader: inlines(options.durationHeader) }
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Lines, one per entry, in every renderer: markdown joins them with hard breaks so they never collapse into one.
|
|
381
|
+
*
|
|
382
|
+
* @param lines - the entries; each takes a string, an inline or an array of either
|
|
383
|
+
*/
|
|
384
|
+
static lines(lines) {
|
|
385
|
+
return freeze({
|
|
386
|
+
_tag: "Lines",
|
|
387
|
+
lines: frozenArray(lines.map(inlines))
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* One line of content; with `truncate`, it is cut to the width with the glyph set's ellipsis instead of wrapping,
|
|
392
|
+
* and with `wrap: false` it is kept whole on one line whatever the width.
|
|
393
|
+
*
|
|
394
|
+
* @remarks
|
|
395
|
+
* By default a line longer than the width wraps. `wrap: false` keeps it atomic in every audience and renderer, still
|
|
396
|
+
* carrying its status glyphs, theme tokens and links, which {@link Doc.verbatim} (a plain string) cannot: the tool for
|
|
397
|
+
* a finding such as `✗ path:line:col rule message` that a reader greps or reads line by line, while the prose around
|
|
398
|
+
* it still wraps. A line break inside it is still a space. With both `truncate` and `wrap: false`, `truncate` wins:
|
|
399
|
+
* the line is cut to the width.
|
|
400
|
+
*
|
|
401
|
+
* @param content - the line
|
|
402
|
+
* @param options - `truncate`, to cut it to the width; `wrap: false`, to keep it whole
|
|
403
|
+
*/
|
|
404
|
+
static line(content, options) {
|
|
405
|
+
return freeze({
|
|
406
|
+
_tag: "Line",
|
|
407
|
+
content: inlines(content),
|
|
408
|
+
...options?.truncate === void 0 ? {} : { truncate: options.truncate },
|
|
409
|
+
...options?.wrap === void 0 ? {} : { wrap: options.wrap }
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* A unified diff as given, such as a test runner's: sanitized, its `+` and `-` lines painted `success` and
|
|
414
|
+
* `failure` in `ansi`, and a `diff` fence in markdown.
|
|
415
|
+
*
|
|
416
|
+
* @remarks
|
|
417
|
+
* With `truncate`, plain and `ansi` cut each line to the width with the glyph set's ellipsis instead of wrapping
|
|
418
|
+
* it; an agent's or a CI's width is unbounded, so nothing is cut for them unless the context gives a finite width.
|
|
419
|
+
* Markdown keeps every line whole. Inside a compact list item a blank line of the diff keeps the item's indent.
|
|
420
|
+
*
|
|
421
|
+
* A trailing line break ends the last line, as in a unified diff file, and adds no blank line after it: `"a\n"` is
|
|
422
|
+
* one line. To end on a blank line, end the text with two line breaks.
|
|
423
|
+
*
|
|
424
|
+
* @param unified - the diff
|
|
425
|
+
* @param options - `cap`, the most lines shown; `truncate`, to cut each line to the width
|
|
426
|
+
*/
|
|
427
|
+
static diffText(unified, options) {
|
|
428
|
+
return freeze({
|
|
429
|
+
_tag: "DiffText",
|
|
430
|
+
text: unified,
|
|
431
|
+
...options?.cap === void 0 ? {} : { cap: options.cap },
|
|
432
|
+
...options?.truncate === void 0 ? {} : { truncate: options.truncate }
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Lines kept exactly: each indented by `indent` spaces, sanitized, and never wrapped.
|
|
437
|
+
*
|
|
438
|
+
* @remarks
|
|
439
|
+
* Plain, `ansi` and `githubLog` write the lines as they are; markdown fences them, so the indentation survives.
|
|
440
|
+
*
|
|
441
|
+
* It is the tool for a single line that must never wrap nor be cut, whatever the width: {@link Doc.line} wraps at
|
|
442
|
+
* the width, or cuts with `truncate`, and `verbatim` does neither.
|
|
443
|
+
*
|
|
444
|
+
* @param text - the lines
|
|
445
|
+
* @param options - `indent`, the spaces in front of every line; none by default
|
|
446
|
+
*/
|
|
447
|
+
static verbatim(text, options) {
|
|
448
|
+
return freeze({
|
|
449
|
+
_tag: "Verbatim",
|
|
450
|
+
text,
|
|
451
|
+
...options?.indent === void 0 ? {} : { indent: options.indent }
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* A GitHub Actions annotation: `Render.githubLog` writes it as one workflow command (`::error file=…::message`),
|
|
456
|
+
* and every other renderer writes nothing.
|
|
457
|
+
*
|
|
458
|
+
* @remarks
|
|
459
|
+
* It is the kit's own command, so `githubLog` does not neutralize it; its message and properties are escaped, so
|
|
460
|
+
* 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
|
|
461
|
+
* top-level section's child, or as a direct child of a group's body. Nested deeper, it is dropped.
|
|
462
|
+
*
|
|
463
|
+
* @param options - the level, and the optional file, position and title
|
|
464
|
+
* @param message - what it says
|
|
465
|
+
*/
|
|
466
|
+
static annotation(options, message) {
|
|
467
|
+
return freeze({
|
|
468
|
+
_tag: "Annotation",
|
|
469
|
+
level: options.level,
|
|
470
|
+
...options.file === void 0 ? {} : { file: options.file },
|
|
471
|
+
...options.line === void 0 ? {} : { line: options.line },
|
|
472
|
+
...options.col === void 0 ? {} : { col: options.col },
|
|
473
|
+
...options.endLine === void 0 ? {} : { endLine: options.endLine },
|
|
474
|
+
...options.endColumn === void 0 ? {} : { endColumn: options.endColumn },
|
|
475
|
+
...options.title === void 0 ? {} : { title: options.title },
|
|
476
|
+
message
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* The total of a `Counts` block: the caller's rule when it has one, otherwise the sum of `n` over every counter.
|
|
481
|
+
*
|
|
482
|
+
* @remarks
|
|
483
|
+
* The rule sees every counter, including the ones a renderer hides, so hiding never changes the total.
|
|
484
|
+
*
|
|
485
|
+
* @param block - the `Counts` block
|
|
486
|
+
*/
|
|
487
|
+
static total(block) {
|
|
488
|
+
return totalOf(block);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* The counters a renderer shows: every one except a zero counter that does not ask for `showZero`.
|
|
492
|
+
*
|
|
493
|
+
* @param block - the `Counts` block
|
|
494
|
+
*/
|
|
495
|
+
static visibleCounters(block) {
|
|
496
|
+
return visibleCountersOf(block);
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Render a document for whoever is running the program and write it to a stream.
|
|
500
|
+
*
|
|
501
|
+
* @remarks
|
|
502
|
+
* The context is {@link Render.context} for the stream, so the width, the colour, the links and the audience
|
|
503
|
+
* come from the services the program already has, and the text is written with `Console.log` or
|
|
504
|
+
* `Console.error`: a test captures it by swapping the `Console`. With `format: "auto"` the renderer follows
|
|
505
|
+
* the audience, and the width is unbounded for an agent, a CI, and a human whose stream is not a terminal.
|
|
506
|
+
*
|
|
507
|
+
* An agent is never written an escape of any kind, even with an explicit `format: "ansi"`: its context is
|
|
508
|
+
* colourless and its links are off. A document that renders to nothing prints nothing.
|
|
509
|
+
*
|
|
510
|
+
* The whole document is written as one `Console.log` (or `Console.error`) call, with its line breaks embedded, so a
|
|
511
|
+
* captured `Console` holds one entry per document, not one per line. Top-level blocks are joined with no blank
|
|
512
|
+
* line between them; wrap them in `Doc.section(undefined, [...])` to space them.
|
|
513
|
+
*
|
|
514
|
+
* `CurrentRuntimeEnv` is read if the environment has one and is not required: a `ci` audience prints
|
|
515
|
+
* GitHub's log format only when it says GitHub Actions, and plain text otherwise, including when it is
|
|
516
|
+
* absent. An explicit `format` is honoured whatever the audience.
|
|
517
|
+
*
|
|
518
|
+
* @param doc - the document
|
|
519
|
+
* @param options - the stream and the format
|
|
520
|
+
*/
|
|
521
|
+
static print = (doc, options) => Effect.gen(function* () {
|
|
522
|
+
const stream = options?.stream ?? "stdout";
|
|
523
|
+
const ctx = yield* Render.context(stream, {
|
|
524
|
+
...options?.displayPath === void 0 ? {} : { displayPath: options.displayPath },
|
|
525
|
+
...options?.width === void 0 ? {} : { width: options.width }
|
|
526
|
+
});
|
|
527
|
+
const requested = options?.format ?? "auto";
|
|
528
|
+
const format = requested === "auto" ? yield* autoFormat(ctx.audience) : requested;
|
|
529
|
+
const text = Render[format](doc, ctx);
|
|
530
|
+
if (text === "") return;
|
|
531
|
+
yield* stream === "stderr" ? Console.error(text) : Console.log(text);
|
|
532
|
+
});
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
//#endregion
|
|
536
|
+
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 };
|