@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
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { Fmt, sanitize } from "../Fmt.js";
|
|
2
|
+
import { counterLabel, countsTableOf, totalOf, visibleCountersOf } from "./counts.js";
|
|
3
|
+
import { isAllowedLinkUrl } from "./linkScheme.js";
|
|
4
|
+
import { DRIVE, encodePath, fileUrlPath } from "./linkTarget.js";
|
|
5
|
+
import { flatten } from "./layout.js";
|
|
6
|
+
import { capOf, isAnnotation, showsSuffix, targetText, textLines } from "./renderDoc.js";
|
|
7
|
+
|
|
8
|
+
//#region src/internal/renderMarkdown.ts
|
|
9
|
+
/** Marks a line break inside flowing text until the paragraph is split into lines. Sanitized text never holds NUL. */
|
|
10
|
+
const BREAK = "\0";
|
|
11
|
+
/**
|
|
12
|
+
* Escape what markdown would read as syntax: backslash, backtick, `*`, `_`, brackets, angle brackets, `&`, `~` and
|
|
13
|
+
* `|` (so text can never form a table), and the start of an autolink literal (any scheme before `://`, or `www.`), which a GFM
|
|
14
|
+
* reader would otherwise turn into a link. An email address is left alone: the `mailto:` link a reader makes of it
|
|
15
|
+
* is harmless, and escaping its `@` does not stop every reader.
|
|
16
|
+
*/
|
|
17
|
+
const escapeText = (text) => text.replace(/[\\`*_[\]<>&~|]/g, "\\$&").replace(/(?<=[a-z0-9+.-]):(?=\/\/)/gi, "\\:").replace(/\b(www)\./gi, "$1\\.");
|
|
18
|
+
/**
|
|
19
|
+
* Escape a block marker at the start of a line: a heading or an ordered item, and always a leading `-`, `+` or `=`,
|
|
20
|
+
* which would make a bullet, a setext underline or (with spaces between dashes) a thematic break.
|
|
21
|
+
*/
|
|
22
|
+
const escapeLineStart = (line) => {
|
|
23
|
+
if (/^[-+=]/.test(line)) return `\\${line}`;
|
|
24
|
+
if (/^#{1,6}(?:\s|$)/.test(line)) return `\\${line}`;
|
|
25
|
+
const ordered = /^(\d{1,9})([.)])(?:\s|$)/.exec(line);
|
|
26
|
+
if (ordered !== null) return `${ordered[1]}\\${line.slice(ordered[1].length)}`;
|
|
27
|
+
return line;
|
|
28
|
+
};
|
|
29
|
+
const htmlEscape = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
30
|
+
/** Whether some `|` in the text has an odd run of backslashes straight before it. */
|
|
31
|
+
const pipeAfterOddBackslashes = (text) => {
|
|
32
|
+
let run = 0;
|
|
33
|
+
for (const ch of text) {
|
|
34
|
+
if (ch === "|" && run % 2 === 1) return true;
|
|
35
|
+
run = ch === "\\" ? run + 1 : 0;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Inline code: a backtick fence longer than any run inside, padded where a reader would strip or merge a space.
|
|
41
|
+
*
|
|
42
|
+
* @remarks
|
|
43
|
+
* In a table cell a `|` is written `\|`, which GFM unescapes before it reads the code span. GFM's row scanner reads a
|
|
44
|
+
* backslash and the character after it as a pair, so a `|` survives the scanner only after an odd run of backslashes,
|
|
45
|
+
* and the unescape then leaves an even run: a code span in a cell cannot hold a `|` after an odd run of backslashes at
|
|
46
|
+
* all. That text is written as `<code>` around escaped text instead, which reads the same; every other `|` follows an
|
|
47
|
+
* even run, which `\|` keeps.
|
|
48
|
+
*/
|
|
49
|
+
const codeSpan = (text, mode) => {
|
|
50
|
+
const flat = text.replace(/\r\n|\r|\n/g, " ");
|
|
51
|
+
if (mode === "cell" && pipeAfterOddBackslashes(flat)) return `<code>${escapeText(flat)}</code>`;
|
|
52
|
+
const longest = Math.max(0, ...[...flat.matchAll(/`+/g)].map((run) => run[0].length));
|
|
53
|
+
const fence = "`".repeat(longest + 1);
|
|
54
|
+
const padded = /^`|`$/.test(flat) || /^ .* $/.test(flat) && flat.trim() !== "" || flat === "";
|
|
55
|
+
const body = mode === "cell" ? flat.replace(/\|/g, "\\|") : flat;
|
|
56
|
+
const pad = padded ? " " : "";
|
|
57
|
+
return `${fence}${pad}${body === "" ? " " : body}${pad}${fence}`;
|
|
58
|
+
};
|
|
59
|
+
const textPiece = (text, mode) => text.split(/\r\n|\r|\n/).map(escapeText).join(mode === "cell" ? "<br>" : mode === "line" ? " " : BREAK);
|
|
60
|
+
/**
|
|
61
|
+
* A link's URL when it has a form a reader can follow: an allowed scheme, a relative URL, or an absolute file path.
|
|
62
|
+
*
|
|
63
|
+
* The scheme is read from a normalised copy, with whitespace and control characters removed and the case folded,
|
|
64
|
+
* because a browser ignores them inside a scheme (`java<tab>script:`). An entity cannot hide one either: the
|
|
65
|
+
* destination escapes `&`, so what a reader decodes is the text that was checked here.
|
|
66
|
+
*/
|
|
67
|
+
const linkUrl = (target, ctx) => {
|
|
68
|
+
if ("url" in target) {
|
|
69
|
+
const url = target.url.trim();
|
|
70
|
+
return isAllowedLinkUrl(url) ? url : void 0;
|
|
71
|
+
}
|
|
72
|
+
if (ctx.linkBase !== void 0) {
|
|
73
|
+
const display = sanitize(ctx.displayPath(target.file)).replace(/\\/g, "/");
|
|
74
|
+
if (display.startsWith("/") || DRIVE.test(display) || display.split("/").includes("..")) return void 0;
|
|
75
|
+
const url = `${ctx.linkBase}${encodePath(display)}${target.line === void 0 ? "" : `#L${target.line}`}`;
|
|
76
|
+
return isAllowedLinkUrl(url) ? url : void 0;
|
|
77
|
+
}
|
|
78
|
+
if (!target.file.startsWith("/") && !DRIVE.test(target.file)) return void 0;
|
|
79
|
+
const path = fileUrlPath(target.file);
|
|
80
|
+
return path === void 0 ? void 0 : `file://${path}`;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* A link destination. `&` is escaped so a reader does not decode an entity into something other than what was written.
|
|
84
|
+
* `|` and the backtick are percent-encoded, which is the same URL: GFM splits a table row into cells before it reads
|
|
85
|
+
* inlines, so a raw `|` would split the row however the destination is written, and a backtick can open a code span.
|
|
86
|
+
*/
|
|
87
|
+
const destination = (raw) => {
|
|
88
|
+
const url = raw.replace(/&/g, "&").replace(/\|/g, "%7C").replace(/`/g, "%60");
|
|
89
|
+
return /^[^\s()<>\\]*$/.test(url) ? url : `<${url.replace(/[\r\n]/g, "").replace(/\\/g, "\\\\").replace(/</g, "%3C").replace(/>/g, "%3E")}>`;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Spans as markdown: each piece escaped, or fenced as code, and `**` or `_` around each stretch of strong or emphasised
|
|
93
|
+
* spans. The spaces at a stretch's edges stay outside its markers, where a reader would otherwise not see emphasis.
|
|
94
|
+
*/
|
|
95
|
+
const emphasized = (spans, mode) => {
|
|
96
|
+
let out = "";
|
|
97
|
+
let i = 0;
|
|
98
|
+
while (i < spans.length) {
|
|
99
|
+
const strong = spans[i].strong === true;
|
|
100
|
+
const em = spans[i].em === true;
|
|
101
|
+
let body = "";
|
|
102
|
+
do {
|
|
103
|
+
const span = spans[i];
|
|
104
|
+
body += span.code === true ? codeSpan(span.text, mode) : textPiece(span.text, mode);
|
|
105
|
+
i++;
|
|
106
|
+
} while (i < spans.length && spans[i].strong === true === strong && spans[i].em === true === em);
|
|
107
|
+
const lead = /^\s*/.exec(body)?.[0] ?? "";
|
|
108
|
+
const core = body.slice(lead.length).trimEnd();
|
|
109
|
+
const trail = body.slice(lead.length + core.length);
|
|
110
|
+
if (!strong && !em || core === "") {
|
|
111
|
+
out += body;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
out += `${lead}${strong ? "**" : ""}${em ? "*" : ""}${core}${em ? "*" : ""}${strong ? "**" : ""}${trail}`;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
};
|
|
118
|
+
/** Inline nodes as markdown: text escaped, code fenced, a link as `[label](url)` or, with no URL form, label and path. */
|
|
119
|
+
const inlineMd = (inlines, ctx, mode) => {
|
|
120
|
+
const flat = flatten(inlines, ctx);
|
|
121
|
+
let out = "";
|
|
122
|
+
let i = 0;
|
|
123
|
+
while (i < flat.length) {
|
|
124
|
+
const link = flat[i].link;
|
|
125
|
+
const start = i;
|
|
126
|
+
do
|
|
127
|
+
i++;
|
|
128
|
+
while (i < flat.length && flat[i].link === link);
|
|
129
|
+
const run = flat.slice(start, i);
|
|
130
|
+
const label = emphasized(run, mode);
|
|
131
|
+
if (link === void 0) {
|
|
132
|
+
out += label;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const raw = run.map((span) => span.text).join("");
|
|
136
|
+
const url = linkUrl(link, ctx);
|
|
137
|
+
const target = targetText(link, ctx);
|
|
138
|
+
if (url !== void 0) out += `[${label}](${destination(url)})`;
|
|
139
|
+
else out += showsSuffix(flat[i - 1].suffix, raw, target) ? `${label} (${codeSpan(target, mode)})` : label;
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
};
|
|
143
|
+
/** Flowing text as lines: blank lines dropped, each line cleared of indentation and of block markers, hard breaks between. */
|
|
144
|
+
const flowLines = (markdown) => {
|
|
145
|
+
const lines = markdown.split(BREAK).map((line) => line.trimStart()).filter((line) => line.trim() !== "").map(escapeLineStart);
|
|
146
|
+
return lines.map((line, index) => index < lines.length - 1 ? `${line}\\` : line.trimEnd());
|
|
147
|
+
};
|
|
148
|
+
const joinBlocks = (blocks) => blocks.filter((block) => block.length > 0).flatMap((block, index) => index === 0 ? block : ["", ...block]);
|
|
149
|
+
/** A line a paragraph ends on: text, rather than a heading, fence, table, quote, list item or HTML. */
|
|
150
|
+
const endsText = (line) => line !== "" && !/^(?:#{1,6}(?:\s|$)|`{3,}|~{3,}|\||>|[-+*] |\d+[.)] |<)/.test(line);
|
|
151
|
+
/**
|
|
152
|
+
* Blocks joined with no blank line between them, for a compact list item. Where one block's text runs straight into
|
|
153
|
+
* the next block's text they would merge into one paragraph, so a hard break keeps them apart.
|
|
154
|
+
*/
|
|
155
|
+
const joinTight = (blocks) => {
|
|
156
|
+
const joined = [];
|
|
157
|
+
for (const block of blocks) {
|
|
158
|
+
if (block.length === 0) continue;
|
|
159
|
+
const last = joined[joined.length - 1];
|
|
160
|
+
if (last !== void 0 && endsText(last) && endsText(block[0] ?? "")) joined[joined.length - 1] = `${last}\\`;
|
|
161
|
+
joined.push(...block);
|
|
162
|
+
}
|
|
163
|
+
return joined;
|
|
164
|
+
};
|
|
165
|
+
const hang = (lines, first, rest) => lines.length === 0 ? [first.trimEnd()] : lines.map((line, index) => `${index === 0 ? first : rest}${line}`.trimEnd());
|
|
166
|
+
const overflowMd = (overflow, hidden, ctx) => flowLines(overflow === void 0 ? escapeText(`${ctx.glyphs.ellipsis} ${hidden} more`) : inlineMd(overflow(hidden), ctx, "flow"));
|
|
167
|
+
const fenced = (lines, info) => {
|
|
168
|
+
const longest = Math.max(0, ...lines.flatMap((line) => [...line.matchAll(/`+/g)].map((run) => run[0].length)));
|
|
169
|
+
const fence = "`".repeat(Math.max(3, longest + 1));
|
|
170
|
+
return [
|
|
171
|
+
`${fence}${info}`,
|
|
172
|
+
...lines,
|
|
173
|
+
fence
|
|
174
|
+
];
|
|
175
|
+
};
|
|
176
|
+
const align = {
|
|
177
|
+
left: ":--",
|
|
178
|
+
right: "--:",
|
|
179
|
+
center: ":-:"
|
|
180
|
+
};
|
|
181
|
+
const tableMd = (header, aligns, rows) => {
|
|
182
|
+
const row = (cells) => `| ${cells.join(" | ")} |`.replace(/ {2}\|/g, " |");
|
|
183
|
+
return [
|
|
184
|
+
row(header),
|
|
185
|
+
row(aligns.map((a) => a === void 0 ? "---" : align[a])),
|
|
186
|
+
...rows.map(row)
|
|
187
|
+
];
|
|
188
|
+
};
|
|
189
|
+
const countsMd = (walk, block) => {
|
|
190
|
+
const { ctx } = walk;
|
|
191
|
+
const visible = visibleCountersOf(block);
|
|
192
|
+
const total = totalOf(block);
|
|
193
|
+
const label = block.label === void 0 ? "" : inlineMd(block.label, ctx, "line").trim();
|
|
194
|
+
const qualifier = block.qualifier === void 0 ? "" : inlineMd(block.qualifier, ctx, "line").trim();
|
|
195
|
+
const duration = block.durationMs === void 0 ? "" : Fmt.duration(block.durationMs);
|
|
196
|
+
const suffix = block.suffix === void 0 ? "" : inlineMd(block.suffix, ctx, "line").trim();
|
|
197
|
+
const name = (counter, count) => escapeText(sanitize(counterLabel(counter, count)).replace(/\r\n|\r|\n/g, " "));
|
|
198
|
+
const headlineName = (counter, index) => index === 0 && block.share !== false ? name(counter, total) : name(counter);
|
|
199
|
+
if (block.layout === "row") {
|
|
200
|
+
const header = [...visible.map(headlineName), ...duration === "" ? [] : ["duration"]];
|
|
201
|
+
const cells = [...visible.map((counter, index) => index === 0 && block.share !== false ? `${counter.n}/${total}` : String(counter.n)), ...duration === "" ? [] : [duration]];
|
|
202
|
+
const after = [qualifier, suffix].filter((part) => part !== "").join(" ");
|
|
203
|
+
return [
|
|
204
|
+
...label === "" ? [] : [flowLines(label)],
|
|
205
|
+
...header.length === 0 ? [] : [tableMd(header, header.map(() => void 0), [cells])],
|
|
206
|
+
...after === "" ? [] : [flowLines(after)]
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
if (block.layout === "columns") return [
|
|
210
|
+
...label === "" ? [] : [flowLines(label)],
|
|
211
|
+
...visible.length === 0 ? [] : [visible.flatMap((counter) => hang([`${escapeLineStart(name(counter).trimStart())}: ${counter.n}`], "- ", " "))],
|
|
212
|
+
...qualifier === "" ? [] : [flowLines(qualifier)],
|
|
213
|
+
...duration === "" ? [] : [[duration]],
|
|
214
|
+
...suffix === "" ? [] : [flowLines(suffix)]
|
|
215
|
+
];
|
|
216
|
+
const tally = visible.map((counter, index) => index === 0 && block.share !== false ? `${counter.n}/${total} ${headlineName(counter, index)}` : `${counter.n} ${name(counter)}`).join(", ");
|
|
217
|
+
const line = [
|
|
218
|
+
[label === "" ? "" : `${label}:`, tally].filter((part) => part !== "").join(" "),
|
|
219
|
+
qualifier,
|
|
220
|
+
duration === "" ? "" : `(${duration})`,
|
|
221
|
+
suffix
|
|
222
|
+
].filter((part) => part !== "").join(" ");
|
|
223
|
+
return line === "" ? [] : [flowLines(line)];
|
|
224
|
+
};
|
|
225
|
+
/**
|
|
226
|
+
* A heading's text with any closing run of `#` escaped, so a reader does not take it for the optional closing sequence.
|
|
227
|
+
*
|
|
228
|
+
* @remarks
|
|
229
|
+
* The text is already markdown: every backslash that came from the document was escaped as text, so a backslash can
|
|
230
|
+
* never sit unescaped in front of the run.
|
|
231
|
+
*/
|
|
232
|
+
const headingText = (markdown) => {
|
|
233
|
+
const text = markdown.trim();
|
|
234
|
+
let start = text.length;
|
|
235
|
+
while (start > 0 && text[start - 1] === "#") start--;
|
|
236
|
+
return `${text.slice(0, start)}${"\\#".repeat(text.length - start)}`;
|
|
237
|
+
};
|
|
238
|
+
const WHITESPACE = /\s/;
|
|
239
|
+
/**
|
|
240
|
+
* A cell's text without the whitespace and `<br>` breaks at either edge, scanned from each end. A regex alternation
|
|
241
|
+
* anchored only at the end retries from every position of an interior run, which is quadratic on a long one.
|
|
242
|
+
*/
|
|
243
|
+
const trimCellEdges = (text) => {
|
|
244
|
+
let start = 0;
|
|
245
|
+
let end = text.length;
|
|
246
|
+
for (;;) if (start < end && WHITESPACE.test(text.charAt(start))) start += 1;
|
|
247
|
+
else if (start + 4 <= end && text.startsWith("<br>", start)) start += 4;
|
|
248
|
+
else break;
|
|
249
|
+
for (;;) if (end > start && WHITESPACE.test(text.charAt(end - 1))) end -= 1;
|
|
250
|
+
else if (end - 4 >= start && text.startsWith("<br>", end - 4)) end -= 4;
|
|
251
|
+
else break;
|
|
252
|
+
return text.slice(start, end);
|
|
253
|
+
};
|
|
254
|
+
/** A block as markdown. `compact` is set on a compact list's item: a section there joins its parts with no blank lines. */
|
|
255
|
+
const blockMd = (walk, block, depth, compact = false) => {
|
|
256
|
+
const { ctx } = walk;
|
|
257
|
+
switch (block._tag) {
|
|
258
|
+
case "Heading": return [`${"#".repeat(block.level)} ${headingText(inlineMd(block.content, ctx, "line"))}`.trimEnd()];
|
|
259
|
+
case "Paragraph": return flowLines(inlineMd(block.content, ctx, "flow"));
|
|
260
|
+
case "List": {
|
|
261
|
+
const cap = capOf(block.cap);
|
|
262
|
+
const listed = block.items.filter((item) => !isAnnotation(item));
|
|
263
|
+
const shown = cap === void 0 ? listed : listed.slice(0, cap);
|
|
264
|
+
const items = shown.flatMap((item) => hang(blockMd(walk, item, depth, block.compact === true), "- ", " "));
|
|
265
|
+
const hidden = listed.length - shown.length;
|
|
266
|
+
return joinBlocks([items, hidden > 0 ? overflowMd(block.overflow, hidden, ctx) : []]);
|
|
267
|
+
}
|
|
268
|
+
case "Table": {
|
|
269
|
+
const columns = Math.max(block.columns.length, ...block.rows.map((row) => row.length));
|
|
270
|
+
if (columns === 0) return [];
|
|
271
|
+
const cap = capOf(block.cap);
|
|
272
|
+
const shown = cap === void 0 ? block.rows : block.rows.slice(0, cap);
|
|
273
|
+
const cell = (inlines) => inlines === void 0 ? "" : trimCellEdges(inlineMd(inlines, ctx, "cell"));
|
|
274
|
+
const header = Array.from({ length: columns }, (_, index) => cell(block.columns[index]?.header));
|
|
275
|
+
const aligns = Array.from({ length: columns }, (_, index) => block.columns[index]?.align);
|
|
276
|
+
const rows = shown.map((row) => Array.from({ length: columns }, (_, index) => cell(row[index])));
|
|
277
|
+
const hidden = block.rows.length - shown.length;
|
|
278
|
+
const overflow = hidden > 0 ? overflowMd(block.overflow, hidden, ctx) : [];
|
|
279
|
+
if (rows.length === 0 && header.every((text) => text === "")) return overflow;
|
|
280
|
+
return joinBlocks([tableMd(header, aligns, rows), overflow]);
|
|
281
|
+
}
|
|
282
|
+
case "Tree": {
|
|
283
|
+
const lines = [];
|
|
284
|
+
const visit = (children, indent) => {
|
|
285
|
+
for (const child of children) {
|
|
286
|
+
const label = flowLines(inlineMd(child.label, ctx, "line"))[0] ?? "";
|
|
287
|
+
lines.push(`${" ".repeat(indent)}- ${label}`.trimEnd());
|
|
288
|
+
visit(child.children, indent + 2);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
visit(block.root.children, 0);
|
|
292
|
+
return joinBlocks([flowLines(inlineMd(block.root.label, ctx, "line")), lines]);
|
|
293
|
+
}
|
|
294
|
+
case "Collapsible": {
|
|
295
|
+
const title = htmlEscape(flatten(block.title, ctx).map((span) => span.text).join("").replace(/\r\n|\r|\n/g, " "));
|
|
296
|
+
const open = block.open === true ? " open" : "";
|
|
297
|
+
const body = joinBlocks(block.body.map((child) => blockMd(walk, child, depth)));
|
|
298
|
+
return [
|
|
299
|
+
`<details${open}><summary>${title}</summary>`,
|
|
300
|
+
"",
|
|
301
|
+
...body.length === 0 ? [] : [...body, ""],
|
|
302
|
+
"</details>"
|
|
303
|
+
];
|
|
304
|
+
}
|
|
305
|
+
case "Callout": {
|
|
306
|
+
const body = joinBlocks(block.body.map((child) => blockMd(walk, child, depth)));
|
|
307
|
+
const first = block.body[0];
|
|
308
|
+
const separate = first !== void 0 && first._tag !== "Paragraph";
|
|
309
|
+
return [
|
|
310
|
+
`[!${block.kind.toUpperCase()}]`,
|
|
311
|
+
...separate ? [""] : [],
|
|
312
|
+
...body
|
|
313
|
+
].map((line) => line === "" ? ">" : `> ${line}`);
|
|
314
|
+
}
|
|
315
|
+
case "CodeBlock": {
|
|
316
|
+
const info = sanitize(block.lang ?? "").trim().split(/\s+/)[0]?.replace(/`/g, "") ?? "";
|
|
317
|
+
return fenced(block.text === "" ? [] : textLines(block.text), info);
|
|
318
|
+
}
|
|
319
|
+
case "Diff": {
|
|
320
|
+
const cap = capOf(block.cap);
|
|
321
|
+
const side = (marker, text) => {
|
|
322
|
+
const lines = textLines(text);
|
|
323
|
+
const shown = cap === void 0 ? lines : lines.slice(0, cap);
|
|
324
|
+
const hidden = lines.length - shown.length;
|
|
325
|
+
return [...shown.map((line) => `${marker} ${line}`.trimEnd()), ...hidden > 0 ? [` ${ctx.glyphs.ellipsis} ${hidden} more lines`] : []];
|
|
326
|
+
};
|
|
327
|
+
return fenced([...side("-", block.expected), ...side("+", block.received)], "diff");
|
|
328
|
+
}
|
|
329
|
+
case "Section": {
|
|
330
|
+
const level = Math.min(2 + depth, 6);
|
|
331
|
+
const parts = [block.title === void 0 ? [] : [`${"#".repeat(level)} ${headingText(inlineMd(block.title, ctx, "line"))}`.trimEnd()], ...block.children.map((child) => blockMd(walk, child, depth + 1))];
|
|
332
|
+
return compact ? joinTight(parts) : joinBlocks(parts);
|
|
333
|
+
}
|
|
334
|
+
case "Counts": return joinBlocks(countsMd(walk, block));
|
|
335
|
+
case "Verbatim": {
|
|
336
|
+
const indent = " ".repeat(Math.max(0, Math.floor(block.indent ?? 0)));
|
|
337
|
+
return fenced(block.text === "" ? [] : textLines(block.text).map((line) => `${indent}${line}`), "");
|
|
338
|
+
}
|
|
339
|
+
case "Annotation": return [];
|
|
340
|
+
case "CountsTable": return blockMd(walk, countsTableOf(block), depth);
|
|
341
|
+
case "Lines": {
|
|
342
|
+
const entries = block.lines.map((entry) => escapeLineStart(inlineMd(entry, ctx, "line").trim()));
|
|
343
|
+
let first = 0;
|
|
344
|
+
let last = entries.length;
|
|
345
|
+
while (first < last && entries[first] === "") first++;
|
|
346
|
+
while (last > first && entries[last - 1] === "") last--;
|
|
347
|
+
const kept = entries.slice(first, last);
|
|
348
|
+
return kept.map((line, index) => index < kept.length - 1 ? `${line}\\` : line);
|
|
349
|
+
}
|
|
350
|
+
case "Line": return flowLines(inlineMd(block.content, ctx, "line"));
|
|
351
|
+
case "DiffText": {
|
|
352
|
+
const cap = capOf(block.cap);
|
|
353
|
+
const lines = block.text === "" ? [] : textLines(block.text);
|
|
354
|
+
const shown = cap === void 0 ? lines : lines.slice(0, cap);
|
|
355
|
+
const hidden = lines.length - shown.length;
|
|
356
|
+
return fenced([...shown, ...hidden > 0 ? [`${ctx.glyphs.ellipsis} ${hidden} more lines`] : []], "diff");
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
/**
|
|
361
|
+
* Render a document as GitHub-flavoured markdown.
|
|
362
|
+
*
|
|
363
|
+
* @internal
|
|
364
|
+
*/
|
|
365
|
+
const renderMarkdown = (doc, ctx) => joinBlocks(doc.map((block) => blockMd({ ctx }, block, 0))).join("\n");
|
|
366
|
+
|
|
367
|
+
//#endregion
|
|
368
|
+
export { renderMarkdown };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { flatten } from "./layout.js";
|
|
2
|
+
import { renderDocLines, showsSuffix, targetText } from "./renderDoc.js";
|
|
3
|
+
|
|
4
|
+
//#region src/internal/renderPlain.ts
|
|
5
|
+
/**
|
|
6
|
+
* Inline content as spans of plain text: code in backticks, and a link as its label followed by its target in
|
|
7
|
+
* parentheses unless the label already is the target. Tokens and links are dropped, as plain text has neither.
|
|
8
|
+
*/
|
|
9
|
+
const plainInline = (inlines, ctx) => {
|
|
10
|
+
const flat = flatten(inlines, ctx);
|
|
11
|
+
const out = [];
|
|
12
|
+
let i = 0;
|
|
13
|
+
while (i < flat.length) {
|
|
14
|
+
const link = flat[i].link;
|
|
15
|
+
let label = "";
|
|
16
|
+
do {
|
|
17
|
+
const span = flat[i];
|
|
18
|
+
label += span.text;
|
|
19
|
+
out.push({ text: span.code === true ? `\`${span.text}\`` : span.text });
|
|
20
|
+
i++;
|
|
21
|
+
} while (link !== void 0 && i < flat.length && flat[i].link === link);
|
|
22
|
+
if (link !== void 0) {
|
|
23
|
+
const target = targetText(link, ctx);
|
|
24
|
+
if (showsSuffix(flat[i - 1].suffix, label, target)) out.push({ text: ` (${target})` });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
};
|
|
29
|
+
const plain = {
|
|
30
|
+
inline: plainInline,
|
|
31
|
+
finish: (line) => line.map((span) => span.text).join("")
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* {@link renderPlain}'s finished lines, one entry per line: a block that draws nothing contributes none.
|
|
35
|
+
*
|
|
36
|
+
* @internal
|
|
37
|
+
*/
|
|
38
|
+
const renderPlainLines = (doc, ctx) => renderDocLines(doc, {
|
|
39
|
+
...ctx,
|
|
40
|
+
audience: "agent"
|
|
41
|
+
}, plain);
|
|
42
|
+
/**
|
|
43
|
+
* Render a document as plain text for an agent: no escape sequences, no decoration.
|
|
44
|
+
*
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
47
|
+
const renderPlain = (doc, ctx) => renderPlainLines(doc, ctx).join("\n");
|
|
48
|
+
|
|
49
|
+
//#endregion
|
|
50
|
+
export { plainInline, renderPlain, renderPlainLines };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
//#region src/internal/scanAudience.ts
|
|
2
|
+
/**
|
|
3
|
+
* The one counting rule, shared by the resolver that runs on parsed flags and by {@link scanAudience}, so the two
|
|
4
|
+
* cannot drift: every `--audience` value counts, and a boolean counts only when it is true (`--agent=false` and
|
|
5
|
+
* `--no-agent` mean "not given").
|
|
6
|
+
*
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
const tallyAudience = (input) => {
|
|
10
|
+
const given = [
|
|
11
|
+
...input.audience,
|
|
12
|
+
...input.human.filter(Boolean).map(() => "human"),
|
|
13
|
+
...input.agent.filter(Boolean).map(() => "agent"),
|
|
14
|
+
...input.ci.filter(Boolean).map(() => "ci")
|
|
15
|
+
];
|
|
16
|
+
return {
|
|
17
|
+
given,
|
|
18
|
+
conflict: given.length > 1
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
const KINDS = [
|
|
22
|
+
"human",
|
|
23
|
+
"agent",
|
|
24
|
+
"ci"
|
|
25
|
+
];
|
|
26
|
+
const TRUE_WORDS = [
|
|
27
|
+
"true",
|
|
28
|
+
"yes",
|
|
29
|
+
"on",
|
|
30
|
+
"1",
|
|
31
|
+
"y"
|
|
32
|
+
];
|
|
33
|
+
const FALSE_WORDS = [
|
|
34
|
+
"false",
|
|
35
|
+
"no",
|
|
36
|
+
"off",
|
|
37
|
+
"0",
|
|
38
|
+
"n"
|
|
39
|
+
];
|
|
40
|
+
const isKind = (value) => KINDS.includes(value);
|
|
41
|
+
/**
|
|
42
|
+
* Read the audience flags straight out of `argv`, the way core will parse them later.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* Pure, and needed because the audience must be known BEFORE core parses: a fallback prompt fires during the
|
|
46
|
+
* parse, which happens before any parsed flag is visible to it. Mirrors the syntax core accepts for the four
|
|
47
|
+
* flags: `--audience v`, `--audience=v`, `--human`, `--agent` and `--ci`, each boolean with an optional `=true`
|
|
48
|
+
* or `=false` (and the other spellings core accepts), or a following `true` or `false` token, `--no-<name>`,
|
|
49
|
+
* repeats, and anywhere before a `--`, which ends flag parsing. A value core would reject (a bad `--audience`
|
|
50
|
+
* kind, an unknown boolean spelling, a missing value) is left uncounted: core reports that as its own usage error.
|
|
51
|
+
*
|
|
52
|
+
* @internal
|
|
53
|
+
*/
|
|
54
|
+
const scanAudience = (argv) => {
|
|
55
|
+
const audience = [];
|
|
56
|
+
const booleans = {
|
|
57
|
+
human: [],
|
|
58
|
+
agent: [],
|
|
59
|
+
ci: []
|
|
60
|
+
};
|
|
61
|
+
const isBoolean = (name) => name === "human" || name === "agent" || name === "ci";
|
|
62
|
+
for (let index = 0; index < argv.length; index++) {
|
|
63
|
+
const token = argv[index] ?? "";
|
|
64
|
+
if (token === "--") break;
|
|
65
|
+
if (!token.startsWith("--")) continue;
|
|
66
|
+
const equals = token.indexOf("=");
|
|
67
|
+
const name = equals === -1 ? token.slice(2) : token.slice(2, equals);
|
|
68
|
+
const inline = equals === -1 ? void 0 : token.slice(equals + 1);
|
|
69
|
+
if (name === "audience") {
|
|
70
|
+
let value = inline;
|
|
71
|
+
if (value === void 0) {
|
|
72
|
+
const next = argv[index + 1];
|
|
73
|
+
if (next !== void 0 && !next.startsWith("-")) {
|
|
74
|
+
value = next;
|
|
75
|
+
index++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (value !== void 0 && isKind(value)) audience.push(value);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (isBoolean(name)) {
|
|
82
|
+
if (inline !== void 0) {
|
|
83
|
+
if (TRUE_WORDS.includes(inline)) booleans[name].push(true);
|
|
84
|
+
else if (FALSE_WORDS.includes(inline)) booleans[name].push(false);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const next = argv[index + 1];
|
|
88
|
+
if (next !== void 0 && TRUE_WORDS.includes(next)) {
|
|
89
|
+
booleans[name].push(true);
|
|
90
|
+
index++;
|
|
91
|
+
} else if (next !== void 0 && FALSE_WORDS.includes(next)) {
|
|
92
|
+
booleans[name].push(false);
|
|
93
|
+
index++;
|
|
94
|
+
} else booleans[name].push(true);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (name.startsWith("no-") && isBoolean(name.slice(3))) booleans[name.slice(3)].push(false);
|
|
98
|
+
}
|
|
99
|
+
return tallyAudience({
|
|
100
|
+
audience,
|
|
101
|
+
...booleans
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
//#endregion
|
|
106
|
+
export { scanAudience, tallyAudience };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
//#region src/internal/splitFrame.ts
|
|
2
|
+
/** What JavaScript's `.` does not match outside dotAll mode: the four line terminators. */
|
|
3
|
+
const isTerminator = (ch) => ch === "\n" || ch === "\r" || ch === "\u2028" || ch === "\u2029";
|
|
4
|
+
/** One UTF-16 unit of JavaScript's `\s`; every character `\s` matches is in the BMP. */
|
|
5
|
+
const isSpace = (ch) => ch !== void 0 && /\s/.test(ch);
|
|
6
|
+
/**
|
|
7
|
+
* A frame text split as `fn (location)`: at the FIRST whitespace run followed by `(`, when the text ends with `)`.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* A linear scan with exactly the result of `/^(.*?)\s+\((.*)\)$/`, which backtracks polynomially on a long whitespace
|
|
11
|
+
* run with no closing parenthesis. The split is the first one, not the last, because a location can hold parentheses
|
|
12
|
+
* (an `eval` frame's `eval at <anonymous> (file:1:2), <anonymous>:1:1`). As with the regular expression, neither the
|
|
13
|
+
* function nor the location may hold a line terminator, though the whitespace between them may.
|
|
14
|
+
*/
|
|
15
|
+
const wrappedFrame = (text) => {
|
|
16
|
+
if (!text.endsWith(")")) return void 0;
|
|
17
|
+
let firstTerminator = -1;
|
|
18
|
+
let lastTerminator = -1;
|
|
19
|
+
for (let i = 0; i < text.length; i++) {
|
|
20
|
+
if (!isTerminator(text[i])) continue;
|
|
21
|
+
if (firstTerminator === -1) firstTerminator = i;
|
|
22
|
+
lastTerminator = i;
|
|
23
|
+
}
|
|
24
|
+
let i = 0;
|
|
25
|
+
while (i < text.length) {
|
|
26
|
+
if (!isSpace(text[i])) {
|
|
27
|
+
i++;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (firstTerminator !== -1 && firstTerminator < i) return void 0;
|
|
31
|
+
let j = i;
|
|
32
|
+
while (isSpace(text[j])) j++;
|
|
33
|
+
if (text[j] === "(" && j <= text.length - 2 && lastTerminator < j) return {
|
|
34
|
+
fn: text.slice(0, i),
|
|
35
|
+
location: text.slice(j + 1, -1)
|
|
36
|
+
};
|
|
37
|
+
i = j;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* A frame's text without `at `, and its location: the part in parentheses, or the whole text when there are none.
|
|
42
|
+
*
|
|
43
|
+
* @internal
|
|
44
|
+
*/
|
|
45
|
+
const splitFrame = (raw) => {
|
|
46
|
+
const text = raw.trim().replace(/^at\s+/, "");
|
|
47
|
+
const wrapped = wrappedFrame(text);
|
|
48
|
+
return {
|
|
49
|
+
text,
|
|
50
|
+
...wrapped === void 0 || wrapped.fn === "" ? {} : { fn: wrapped.fn },
|
|
51
|
+
location: wrapped === void 0 ? text : wrapped.location
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
//#endregion
|
|
56
|
+
export { splitFrame };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/wizardGate.ts
|
|
4
|
+
/**
|
|
5
|
+
* The exact `CliConfig` object `CliPrompt.gateWizard` produced when it removed `--wizard`, or `undefined` when it
|
|
6
|
+
* removed nothing.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* It is how a later decision can put the flag back without inventing it: the restore happens only when the CURRENT
|
|
10
|
+
* config is this very object. A consumer who left `Wizard` out of their own `builtIns` never has it set, and one who
|
|
11
|
+
* provides their own `CliConfig` inside the gate has a different object, so neither gets a wizard they did not ask for.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
var WizardDropped = class extends Context.Reference("@effected/cli/WizardDropped", { defaultValue: () => void 0 }) {};
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
18
|
+
export { WizardDropped };
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "The boundary
|
|
5
|
+
"description": "The presentation boundary of an effect/cli program: audience-aware output, a document IR and renderers, editor links, failure reports and logging, plus opt-in Ink screens, widgets and a live view",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"effect",
|
|
8
8
|
"cli",
|
|
9
9
|
"logger",
|
|
10
|
-
"terminal"
|
|
10
|
+
"terminal",
|
|
11
|
+
"ink",
|
|
12
|
+
"tui"
|
|
11
13
|
],
|
|
12
14
|
"homepage": "https://github.com/spencerbeggs/effected/tree/main/packages/cli#readme",
|
|
13
15
|
"bugs": {
|
|
@@ -37,15 +39,48 @@
|
|
|
37
39
|
"import": "./testing.js",
|
|
38
40
|
"default": "./testing.js"
|
|
39
41
|
},
|
|
42
|
+
"./ui": {
|
|
43
|
+
"types": "./ui.d.ts",
|
|
44
|
+
"import": "./ui.js",
|
|
45
|
+
"default": "./ui.js"
|
|
46
|
+
},
|
|
47
|
+
"./ui/testing": {
|
|
48
|
+
"types": "./ui-testing.d.ts",
|
|
49
|
+
"import": "./ui-testing.js",
|
|
50
|
+
"default": "./ui-testing.js"
|
|
51
|
+
},
|
|
52
|
+
"./ui/testing/serializer": {
|
|
53
|
+
"types": "./ui-testing-serializer.d.ts",
|
|
54
|
+
"import": "./ui-testing-serializer.js",
|
|
55
|
+
"default": "./ui-testing-serializer.js"
|
|
56
|
+
},
|
|
40
57
|
"./package.json": "./package.json"
|
|
41
58
|
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@effected/github-commands": "^0.1.0"
|
|
61
|
+
},
|
|
42
62
|
"peerDependencies": {
|
|
43
|
-
"@effected/config-file": "^0.
|
|
44
|
-
"
|
|
63
|
+
"@effected/config-file": "^0.14.1",
|
|
64
|
+
"@effected/env": "^0.1.0",
|
|
65
|
+
"@effected/glob": "^0.10.0",
|
|
66
|
+
"@effected/walker": "^0.15.0",
|
|
67
|
+
"@types/react": "^19.2.0",
|
|
68
|
+
"effect": "^4.0.0",
|
|
69
|
+
"ink": "^7.1.1",
|
|
70
|
+
"react": "^19.2.0"
|
|
45
71
|
},
|
|
46
72
|
"peerDependenciesMeta": {
|
|
47
73
|
"@effected/config-file": {
|
|
48
74
|
"optional": true
|
|
75
|
+
},
|
|
76
|
+
"@types/react": {
|
|
77
|
+
"optional": true
|
|
78
|
+
},
|
|
79
|
+
"ink": {
|
|
80
|
+
"optional": true
|
|
81
|
+
},
|
|
82
|
+
"react": {
|
|
83
|
+
"optional": true
|
|
49
84
|
}
|
|
50
85
|
},
|
|
51
86
|
"engines": {
|