@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.
- package/Cancelled.js +44 -0
- package/CliAudience.js +178 -0
- package/CliColor.js +14 -20
- package/CliEnv.js +89 -0
- package/CliExit.js +1 -1
- package/CliFailure.js +253 -0
- package/CliInteractive.js +71 -0
- package/CliLinks.js +154 -0
- package/CliLog.js +294 -0
- package/CliLogger.js +34 -33
- package/CliMessage.js +83 -0
- package/CliPrompt.js +104 -0
- package/CliRuntime.js +104 -54
- package/CliTest.js +18 -2
- package/CliTheme.js +128 -0
- package/ConfigIssueRenderer.js +14 -33
- package/Doc.js +512 -0
- package/Fmt.js +133 -0
- package/GithubAnnotation.js +40 -0
- package/Glyphs.js +83 -0
- package/NotInteractive.js +42 -0
- package/README.md +129 -131
- package/Render.js +254 -0
- package/SchemaIssueRenderer.js +7 -10
- package/Status.js +163 -0
- package/TestTerminal.js +80 -0
- package/Token.js +69 -0
- package/index.d.ts +2923 -171
- package/index.js +19 -1
- package/internal/HelpRouting.js +1 -1
- package/internal/ansi.js +230 -0
- package/internal/autoFormat.js +34 -0
- package/internal/canPrompt.js +15 -0
- package/internal/counts.js +69 -0
- package/internal/diagnostics.js +32 -0
- package/internal/displayWidth.js +35 -0
- package/internal/failureTarget.js +156 -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 +319 -0
- package/internal/renderGithubLog.js +46 -0
- package/internal/renderMarkdown.js +367 -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 +35 -5
- package/testing.d.ts +90 -4
- package/testing.js +2 -1
- package/ui/CliUi.js +348 -0
- package/ui/CliUiLive.js +399 -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 +226 -0
- package/ui/Tabs.js +202 -0
- package/ui/TextInput.js +250 -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/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 +735 -0
- package/ui/testing/fakeStreams.js +76 -0
- package/ui/testing/terminalModel.js +59 -0
- package/ui-testing.d.ts +446 -0
- package/ui-testing.js +3 -0
- package/ui.d.ts +1648 -0
- package/ui.js +17 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { sanitize } from "../Fmt.js";
|
|
2
|
+
import { formatNdjson, passes } from "./diagnostics.js";
|
|
3
|
+
import { Cause, Console, Effect, Exit, Fiber, FileSystem, Logger, Path, Queue } from "effect";
|
|
4
|
+
import { CommandNeutralizer } from "@effected/github-commands";
|
|
5
|
+
|
|
6
|
+
//#region src/internal/fileSink.ts
|
|
7
|
+
/** How long closing the scope waits for queued lines to reach the file before giving up on a hung filesystem. */
|
|
8
|
+
const CLOSE_TIMEOUT = "2 seconds";
|
|
9
|
+
/**
|
|
10
|
+
* An asynchronous NDJSON file logger.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* `Logger.make` takes a synchronous callback, so the logger only offers the line to a queue; a fiber scoped to the
|
|
14
|
+
* layer drains it and appends each batch with `FileSystem.writeFileString(..., { flag: "a" })`. The first write
|
|
15
|
+
* error prints one stderr line and disables the sink: later lines, including any still queued, are discarded
|
|
16
|
+
* without a message. A defect from the filesystem counts as a write error. Closing the scope ends the queue and
|
|
17
|
+
* waits for the drain for at most two seconds, so lines queued before the close are flushed unless the sink had
|
|
18
|
+
* already disabled itself or the filesystem hangs; past the bound the drain is interrupted and the rest is lost.
|
|
19
|
+
*
|
|
20
|
+
* The stderr line is held to the rules of every other kit write: the path (which a workflow can set through an env
|
|
21
|
+
* var) and the error's message are sanitised, and the line is neutralized when `underActions` says the drain's fiber
|
|
22
|
+
* runs under GitHub Actions, the same decision the diagnostics sink makes.
|
|
23
|
+
*
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
const makeFileSink = (path, installed, underActions) => Effect.gen(function* () {
|
|
27
|
+
const fs = yield* FileSystem.FileSystem;
|
|
28
|
+
const location = yield* Path.Path;
|
|
29
|
+
const queue = yield* Queue.unbounded();
|
|
30
|
+
let disabled = false;
|
|
31
|
+
let directoryMade = false;
|
|
32
|
+
const append = (lines) => Effect.gen(function* () {
|
|
33
|
+
if (!directoryMade) {
|
|
34
|
+
yield* fs.makeDirectory(location.dirname(path), { recursive: true });
|
|
35
|
+
directoryMade = true;
|
|
36
|
+
}
|
|
37
|
+
yield* fs.writeFileString(path, lines.map((line) => `${line}\n`).join(""), { flag: "a" });
|
|
38
|
+
});
|
|
39
|
+
const drain = Effect.gen(function* () {
|
|
40
|
+
while (true) {
|
|
41
|
+
const batch = yield* Queue.takeAll(queue);
|
|
42
|
+
if (disabled) continue;
|
|
43
|
+
const exit = yield* Effect.exit(append(batch));
|
|
44
|
+
if (Exit.isFailure(exit)) {
|
|
45
|
+
disabled = true;
|
|
46
|
+
const error = Cause.squash(exit.cause);
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
const line = sanitize(`diagnostics log file ${path} failed: ${message}; further file logging disabled`);
|
|
49
|
+
yield* Effect.withFiber((self) => Console.error(underActions(self) ? CommandNeutralizer.text(line) : line));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}).pipe(Effect.ignore);
|
|
53
|
+
const fiber = yield* Effect.forkScoped(drain);
|
|
54
|
+
yield* Effect.addFinalizer(() => Queue.end(queue).pipe(Effect.andThen(Fiber.join(fiber).pipe(Effect.timeout(CLOSE_TIMEOUT))), Effect.ignore));
|
|
55
|
+
return Logger.make((record) => {
|
|
56
|
+
if (!passes(record, installed)) return;
|
|
57
|
+
Queue.offerUnsafe(queue, formatNdjson(record));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
//#endregion
|
|
62
|
+
export { makeFileSink };
|
package/internal/format.js
CHANGED
|
@@ -37,14 +37,69 @@ const formatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (issue)
|
|
|
37
37
|
*
|
|
38
38
|
* @internal
|
|
39
39
|
*/
|
|
40
|
-
const formatIssue = (issue) => {
|
|
40
|
+
const formatIssue = (issue) => issueEntries(issue).map((entry) => entry.path.length === 0 ? entry.message : `${entry.message} at ${entry.path.join(".")}`);
|
|
41
|
+
/**
|
|
42
|
+
* The rejected values of an issue tree, once each: what both the lines and the tree are built from.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* Anything that is not an issue tree yields none rather than throwing. A rendering helper on an error path must never
|
|
46
|
+
* become the reason a program dies: it is called when something has already gone wrong. A union reports every branch
|
|
47
|
+
* it tried, so one wrong key in a three-member union is the same entry three times; the per-branch "Missing key"
|
|
48
|
+
* entries differ and are kept, since they say which shapes were allowed, but the repeat is pure noise in front of them.
|
|
49
|
+
*
|
|
50
|
+
* @internal
|
|
51
|
+
*/
|
|
52
|
+
const issueEntries = (issue) => {
|
|
41
53
|
if (!SchemaIssue.isIssue(issue)) return [];
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
const seen = /* @__PURE__ */ new Set();
|
|
55
|
+
const entries = [];
|
|
56
|
+
for (const entry of formatter(issue).issues) {
|
|
57
|
+
const path = (entry.path ?? []).map(String);
|
|
58
|
+
const key = path.length === 0 ? entry.message : `${entry.message} at ${path.join(".")}`;
|
|
59
|
+
if (seen.has(key)) continue;
|
|
60
|
+
seen.add(key);
|
|
61
|
+
entries.push({
|
|
62
|
+
message: entry.message,
|
|
63
|
+
path
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return entries;
|
|
67
|
+
};
|
|
68
|
+
const emptyNode = () => ({
|
|
69
|
+
messages: [],
|
|
70
|
+
children: /* @__PURE__ */ new Map()
|
|
71
|
+
});
|
|
72
|
+
/**
|
|
73
|
+
* The entries as the input of a `Doc.tree`: one node per path segment, so `groups.g.extra` is three nested nodes, and
|
|
74
|
+
* the message at the end of a path is the node's label (`extra: unknown key`) or, when the node has more to say, its
|
|
75
|
+
* leaves.
|
|
76
|
+
*
|
|
77
|
+
* @internal
|
|
78
|
+
*/
|
|
79
|
+
const issueTreeChildren = (entries) => {
|
|
80
|
+
const root = emptyNode();
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
let node = root;
|
|
83
|
+
for (const segment of entry.path) {
|
|
84
|
+
let next = node.children.get(segment);
|
|
85
|
+
if (next === void 0) {
|
|
86
|
+
next = emptyNode();
|
|
87
|
+
node.children.set(segment, next);
|
|
88
|
+
}
|
|
89
|
+
node = next;
|
|
90
|
+
}
|
|
91
|
+
node.messages.push(entry.message);
|
|
92
|
+
}
|
|
93
|
+
const leaves = (node) => node.messages.map((message) => ({ label: message }));
|
|
94
|
+
const named = (segment, node) => {
|
|
95
|
+
if (node.children.size === 0 && node.messages.length === 1) return { label: `${segment}: ${node.messages[0]}` };
|
|
96
|
+
return {
|
|
97
|
+
label: segment,
|
|
98
|
+
children: [...leaves(node), ...[...node.children].map(([name, child]) => named(name, child))]
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
return [...leaves(root), ...[...root.children].map(([name, child]) => named(name, child))];
|
|
47
102
|
};
|
|
48
103
|
|
|
49
104
|
//#endregion
|
|
50
|
-
export { formatIssue };
|
|
105
|
+
export { formatIssue, issueEntries, issueTreeChildren };
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { displayWidth, graphemes } from "./displayWidth.js";
|
|
2
|
+
import { sanitize } from "../Fmt.js";
|
|
3
|
+
|
|
4
|
+
//#region src/internal/layout.ts
|
|
5
|
+
const pathSeparator = (ctx) => ctx.audience === "agent" ? ctx.glyphs.pathSeparator.agent : ` ${ctx.glyphs.pathSeparator.human} `;
|
|
6
|
+
/** A link target is sanitized like content, and loses its line breaks, which no URL or path holds. */
|
|
7
|
+
const safeTargetText = (text) => sanitize(text).replace(/[\r\n]/g, "");
|
|
8
|
+
const safeTarget = (target) => "url" in target ? { url: safeTargetText(target.url) } : {
|
|
9
|
+
...target,
|
|
10
|
+
file: safeTargetText(target.file)
|
|
11
|
+
};
|
|
12
|
+
const spansOf = (inline, ctx) => {
|
|
13
|
+
switch (inline._tag) {
|
|
14
|
+
case "Text": return [{
|
|
15
|
+
text: sanitize(inline.value),
|
|
16
|
+
...inline.token === void 0 ? {} : { token: inline.token }
|
|
17
|
+
}];
|
|
18
|
+
case "Code": return [{
|
|
19
|
+
text: sanitize(inline.value),
|
|
20
|
+
code: true
|
|
21
|
+
}];
|
|
22
|
+
case "Link": {
|
|
23
|
+
const link = safeTarget(inline.target);
|
|
24
|
+
const suffix = inline.suffix === void 0 ? {} : { suffix: inline.suffix };
|
|
25
|
+
return inline.label.flatMap((part) => spansOf(part, ctx)).map((span) => ({
|
|
26
|
+
...span,
|
|
27
|
+
link,
|
|
28
|
+
...suffix
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
case "StatusMark": return [{
|
|
32
|
+
text: sanitize(ctx.glyphs.kind === "ascii" ? inline.def.ascii : inline.def.glyph),
|
|
33
|
+
token: inline.def.token,
|
|
34
|
+
glyph: true
|
|
35
|
+
}];
|
|
36
|
+
case "Path": return [{ text: inline.segments.map(sanitize).join(pathSeparator(ctx)) }];
|
|
37
|
+
case "Strong": return inline.content.flatMap((part) => spansOf(part, ctx)).map((span) => ({
|
|
38
|
+
...span,
|
|
39
|
+
strong: true
|
|
40
|
+
}));
|
|
41
|
+
case "Emphasis": return inline.content.flatMap((part) => spansOf(part, ctx)).map((span) => ({
|
|
42
|
+
...span,
|
|
43
|
+
em: true
|
|
44
|
+
}));
|
|
45
|
+
case "File": return [{ text: safeTargetText(ctx.displayPath(inline.path)) }];
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Flatten inline nodes into spans.
|
|
50
|
+
*
|
|
51
|
+
* @remarks
|
|
52
|
+
* A `Path` becomes its segments joined by the audience's separator (`›` with spaces around it for a person, ` > `
|
|
53
|
+
* for an agent), a `StatusMark` its glyph from the context's glyph set painted with the definition's token, and a
|
|
54
|
+
* `Code` its text with `code` set. Escape sequences in content and in link targets are removed and empty spans
|
|
55
|
+
* dropped.
|
|
56
|
+
*
|
|
57
|
+
* @internal
|
|
58
|
+
*/
|
|
59
|
+
const flatten = (inlines, ctx) => inlines.flatMap((inline) => spansOf(inline, ctx)).filter((span) => span.text !== "");
|
|
60
|
+
/**
|
|
61
|
+
* The display width of spans in terminal columns.
|
|
62
|
+
*
|
|
63
|
+
* @remarks
|
|
64
|
+
* Graphemes are measured within a span, deliberately: a cluster split across two spans (the two halves of a flag,
|
|
65
|
+
* a joiner and the next emoji) counts as two characters, as it is laid out. Content does not split a cluster unless
|
|
66
|
+
* the caller does.
|
|
67
|
+
*
|
|
68
|
+
* @internal
|
|
69
|
+
*/
|
|
70
|
+
const widthOf = (spans) => spans.reduce((sum, span) => sum + displayWidth(span.text), 0);
|
|
71
|
+
const cellsOf = (spans) => spans.flatMap((span) => graphemes(span.text).map((grapheme) => ({
|
|
72
|
+
grapheme,
|
|
73
|
+
width: displayWidth(grapheme),
|
|
74
|
+
span
|
|
75
|
+
})));
|
|
76
|
+
/** Rebuild spans from cells, merging a run that came from the same span. */
|
|
77
|
+
const spansFromCells = (cells) => {
|
|
78
|
+
const out = [];
|
|
79
|
+
let current;
|
|
80
|
+
for (const cell of cells) if (cell.span === current) {
|
|
81
|
+
const last = out[out.length - 1];
|
|
82
|
+
out[out.length - 1] = {
|
|
83
|
+
...last,
|
|
84
|
+
text: last.text + cell.grapheme
|
|
85
|
+
};
|
|
86
|
+
} else {
|
|
87
|
+
current = cell.span;
|
|
88
|
+
out.push({
|
|
89
|
+
...cell.span,
|
|
90
|
+
text: cell.grapheme
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Cut spans to at most `width` columns, marking the cut with `ellipsis`.
|
|
97
|
+
*
|
|
98
|
+
* @remarks
|
|
99
|
+
* Cuts on grapheme boundaries and before any painting, so a colour or hyperlink is never cut in half: the kept
|
|
100
|
+
* text keeps its span's token and link, and the marker joins the last kept span (or follows a `Code` span as plain
|
|
101
|
+
* text under the same link). When nothing but the marker fits, it takes the first span's token and link, as it
|
|
102
|
+
* stands for the whole label. Spans that already fit are returned as they are. An ellipsis that cannot fit is
|
|
103
|
+
* omitted, and a `width` of 0 or less gives no spans.
|
|
104
|
+
*
|
|
105
|
+
* @internal
|
|
106
|
+
*/
|
|
107
|
+
const truncateSpans = (spans, width, ellipsis) => {
|
|
108
|
+
const limit = Number.isNaN(width) ? 0 : Math.floor(width);
|
|
109
|
+
if (limit <= 0) return [];
|
|
110
|
+
if (widthOf(spans) <= limit) return spans;
|
|
111
|
+
const ellipsisWidth = displayWidth(ellipsis);
|
|
112
|
+
const useEllipsis = ellipsis !== "" && ellipsisWidth <= limit;
|
|
113
|
+
const budget = useEllipsis ? limit - ellipsisWidth : limit;
|
|
114
|
+
const kept = [];
|
|
115
|
+
let used = 0;
|
|
116
|
+
for (const cell of cellsOf(spans)) {
|
|
117
|
+
if (used + cell.width > budget) break;
|
|
118
|
+
kept.push(cell);
|
|
119
|
+
used += cell.width;
|
|
120
|
+
}
|
|
121
|
+
const out = spansFromCells(kept);
|
|
122
|
+
if (!useEllipsis) return out;
|
|
123
|
+
const last = out[out.length - 1];
|
|
124
|
+
if (last === void 0) {
|
|
125
|
+
const first = spans[0];
|
|
126
|
+
return [{
|
|
127
|
+
text: ellipsis,
|
|
128
|
+
...first.token === void 0 ? {} : { token: first.token },
|
|
129
|
+
...first.link === void 0 ? {} : { link: first.link }
|
|
130
|
+
}];
|
|
131
|
+
}
|
|
132
|
+
if (last.code === true) return [...out, {
|
|
133
|
+
text: ellipsis,
|
|
134
|
+
...last.link === void 0 ? {} : { link: last.link }
|
|
135
|
+
}];
|
|
136
|
+
return [...out.slice(0, -1), {
|
|
137
|
+
...last,
|
|
138
|
+
text: last.text + ellipsis
|
|
139
|
+
}];
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* Paint spans, then link them: the string a terminal shows.
|
|
143
|
+
*
|
|
144
|
+
* @remarks
|
|
145
|
+
* Each span is painted with its token, and consecutive spans that share a link (by identity) are wrapped in one
|
|
146
|
+
* hyperlink. Truncate and wrap first: painting is last, so an escape sequence is never cut.
|
|
147
|
+
*
|
|
148
|
+
* @internal
|
|
149
|
+
*/
|
|
150
|
+
const paintSpans = (spans, ctx) => {
|
|
151
|
+
const paint = (span) => {
|
|
152
|
+
const toned = span.token === void 0 ? span.text : ctx.paint(span.token, span.text);
|
|
153
|
+
const bold = span.strong === true ? ctx.paint({ bold: true }, toned) : toned;
|
|
154
|
+
return span.em === true ? ctx.paint({ italic: true }, bold) : bold;
|
|
155
|
+
};
|
|
156
|
+
let out = "";
|
|
157
|
+
let i = 0;
|
|
158
|
+
while (i < spans.length) {
|
|
159
|
+
const start = spans[i];
|
|
160
|
+
let run = paint(start);
|
|
161
|
+
i++;
|
|
162
|
+
if (start.link === void 0) {
|
|
163
|
+
out += run;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
while (i < spans.length && spans[i].link === start.link) {
|
|
167
|
+
run += paint(spans[i]);
|
|
168
|
+
i++;
|
|
169
|
+
}
|
|
170
|
+
out += ctx.link(start.link, run);
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
};
|
|
174
|
+
const isLineBreak = (grapheme) => grapheme === "\n" || grapheme === "\r\n" || grapheme === "\r";
|
|
175
|
+
const cellsWidth = (cells) => cells.reduce((sum, cell) => sum + cell.width, 0);
|
|
176
|
+
/**
|
|
177
|
+
* Word-wrap spans to `width` columns.
|
|
178
|
+
*
|
|
179
|
+
* @remarks
|
|
180
|
+
* Spaces are the break points, and the space at a break is dropped, as are trailing spaces; spaces inside a line,
|
|
181
|
+
* and the indentation of the first line or one after a newline, are kept. A newline is a forced break. A word
|
|
182
|
+
* longer than the width starts on a line of its own and is broken at the edge, never inside a grapheme, so a wide
|
|
183
|
+
* character never straddles it. A grapheme wider than the width (a width of 1 and a wide character) takes a line
|
|
184
|
+
* to itself, the only way to make progress. With `hardBreak: false` a long word is not broken at all: it keeps a line
|
|
185
|
+
* of its own and runs past the width, which is right for a URL or a path. A `width` under 1 is treated as 1. Each character keeps its span's
|
|
186
|
+
* token and link.
|
|
187
|
+
*
|
|
188
|
+
* @internal
|
|
189
|
+
*/
|
|
190
|
+
const wrapSpans = (spans, width, options) => {
|
|
191
|
+
const limit = Number.isNaN(width) ? 1 : Math.max(1, Math.floor(width));
|
|
192
|
+
const cells = cellsOf(spans);
|
|
193
|
+
const lines = [];
|
|
194
|
+
let line = [];
|
|
195
|
+
let used = 0;
|
|
196
|
+
let keepLeading = true;
|
|
197
|
+
const flush = () => {
|
|
198
|
+
lines.push(line);
|
|
199
|
+
line = [];
|
|
200
|
+
used = 0;
|
|
201
|
+
};
|
|
202
|
+
let i = 0;
|
|
203
|
+
while (i < cells.length) {
|
|
204
|
+
if (isLineBreak(cells[i].grapheme)) {
|
|
205
|
+
flush();
|
|
206
|
+
keepLeading = true;
|
|
207
|
+
i++;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const spaces = [];
|
|
211
|
+
while (i < cells.length && cells[i].grapheme === " ") spaces.push(cells[i++]);
|
|
212
|
+
const word = [];
|
|
213
|
+
while (i < cells.length) {
|
|
214
|
+
const { grapheme } = cells[i];
|
|
215
|
+
if (grapheme === " " || isLineBreak(grapheme)) break;
|
|
216
|
+
word.push(cells[i++]);
|
|
217
|
+
}
|
|
218
|
+
if (word.length === 0) continue;
|
|
219
|
+
const lead = line.length > 0 || keepLeading ? spaces : [];
|
|
220
|
+
const leadWidth = cellsWidth(lead);
|
|
221
|
+
const wordWidth = cellsWidth(word);
|
|
222
|
+
if (used + leadWidth + wordWidth <= limit) {
|
|
223
|
+
line.push(...lead, ...word);
|
|
224
|
+
used += leadWidth + wordWidth;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (line.length > 0) {
|
|
228
|
+
flush();
|
|
229
|
+
keepLeading = false;
|
|
230
|
+
}
|
|
231
|
+
if (wordWidth <= limit || options?.hardBreak === false) {
|
|
232
|
+
line.push(...word);
|
|
233
|
+
used = wordWidth;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
for (const cell of word) {
|
|
237
|
+
if (used + cell.width > limit && line.length > 0) {
|
|
238
|
+
flush();
|
|
239
|
+
keepLeading = false;
|
|
240
|
+
}
|
|
241
|
+
line.push(cell);
|
|
242
|
+
used += cell.width;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (line.length > 0 || lines.length === 0) flush();
|
|
246
|
+
return lines.map(spansFromCells);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
//#endregion
|
|
250
|
+
export { flatten, paintSpans, truncateSpans, widthOf, wrapSpans };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { sanitize } from "../Fmt.js";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/linkScheme.ts
|
|
4
|
+
/** The URL schemes a link may have, besides a relative URL. */
|
|
5
|
+
const ALLOWED = /* @__PURE__ */ new Set([
|
|
6
|
+
"http",
|
|
7
|
+
"https",
|
|
8
|
+
"mailto",
|
|
9
|
+
"file",
|
|
10
|
+
"vscode",
|
|
11
|
+
"vscode-insiders"
|
|
12
|
+
]);
|
|
13
|
+
/**
|
|
14
|
+
* Whether a link may point at this URL: its scheme is one of `http`, `https`, `mailto`, `file`, `vscode` and
|
|
15
|
+
* `vscode-insiders`, or it has none (a relative URL or a fragment).
|
|
16
|
+
*
|
|
17
|
+
* @remarks
|
|
18
|
+
* The scheme is read from a normalised copy, with control characters and whitespace removed and the case folded,
|
|
19
|
+
* because a browser ignores them inside a scheme (`java<tab>script:`). One list serves every renderer that writes a
|
|
20
|
+
* link, so `javascript:`, `data:` and the like are refused the same way in markdown and in a terminal's OSC 8.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
const isAllowedLinkUrl = (url) => {
|
|
25
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/.exec(sanitize(url).replace(/\s/g, "").toLowerCase());
|
|
26
|
+
return scheme === null || ALLOWED.has(scheme[1]);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
//#endregion
|
|
30
|
+
export { isAllowedLinkUrl };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//#region src/internal/linkTarget.ts
|
|
2
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
3
|
+
/**
|
|
4
|
+
* A Windows drive path (`C:\x`, `C:/x`): absolute whatever the `Path` flavour, so never resolved against a directory.
|
|
5
|
+
*
|
|
6
|
+
* The one false positive is a relative POSIX path whose first directory is a single letter followed by a colon, such
|
|
7
|
+
* as `a:/b.txt`, which reads as drive `a`. Such a filename is vanishingly rare, and recognising a drive is what makes
|
|
8
|
+
* `C:\x` work on a POSIX `Path`, where it is otherwise a relative filename. A colon anywhere else (`/a/C:b`,
|
|
9
|
+
* `./C:x`, `src/C:/x.ts`) is data and stays encoded.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
const DRIVE = /^[A-Za-z]:[\\/]/;
|
|
14
|
+
/**
|
|
15
|
+
* A UNC path (`\\server\share\a.ts`, `//server/share/a.ts`): neither a drive nor a path on this machine, so it has no
|
|
16
|
+
* link target. Left to a POSIX `Path` it would be read as a relative filename and linked to a file that does not exist.
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
const UNC = /^(?:\\\\|\/\/)/;
|
|
21
|
+
/**
|
|
22
|
+
* RFC 3986: everything but the unreserved characters is percent-encoded, in each segment, and `/` is kept.
|
|
23
|
+
*
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
const encodePath = (path) => path.replace(LONE_SURROGATE, "�").split("/").map((segment) => encodeURIComponent(segment).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)).join("/");
|
|
27
|
+
/**
|
|
28
|
+
* The encoded path part of a `file:` URL for an absolute path (the part after `file://`), or `undefined` for a UNC
|
|
29
|
+
* path. One builder, so `CliLinks` and `Render.markdown` link a file the same way.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* A drive path keeps its drive and becomes `/C:/x/y.ts`: the colon is part of the URL's path, not data to encode.
|
|
33
|
+
*
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
const fileUrlPath = (absolute) => {
|
|
37
|
+
if (UNC.test(absolute)) return void 0;
|
|
38
|
+
if (DRIVE.test(absolute)) return `/${absolute.slice(0, 2)}${encodePath(absolute.slice(2).replace(/\\/g, "/"))}`;
|
|
39
|
+
return encodePath(absolute.startsWith("/") ? absolute : `/${absolute.replace(/\\/g, "/")}`);
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* A URL for an OSC 8 sequence: every character outside printable ASCII (32 to 126) is percent-encoded as its UTF-8
|
|
43
|
+
* bytes, as the OSC 8 convention asks, and nothing already encoded is touched, so `%C3%A9` is not encoded twice.
|
|
44
|
+
*
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
47
|
+
const encodeForOsc8 = (url) => url.replace(LONE_SURROGATE, "�").replace(/[^\x20-\x7e]/gu, (char) => encodeURIComponent(char));
|
|
48
|
+
|
|
49
|
+
//#endregion
|
|
50
|
+
export { DRIVE, UNC, encodeForOsc8, encodePath, fileUrlPath };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { sanitize } from "../Fmt.js";
|
|
2
|
+
import { Context, Option } from "effect";
|
|
3
|
+
import { CurrentRuntimeEnv } from "@effected/env";
|
|
4
|
+
|
|
5
|
+
//#region src/internal/logSafety.ts
|
|
6
|
+
/**
|
|
7
|
+
* Marks a log line the kit has already rendered, so the logger does not strip the escapes the kit painted into it.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* The failure report renders a document for the audience (painted for a person) and writes each line through the
|
|
11
|
+
* logger. That text is not consumer-supplied any more: its consumer text was sanitised when the document was built. A
|
|
12
|
+
* `Reference` rather than a log annotation, so it never appears in a diagnostics record.
|
|
13
|
+
*
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
const TrustedLine = Context.Reference("@effected/cli/TrustedLine", { defaultValue: () => false });
|
|
17
|
+
/**
|
|
18
|
+
* Whether the logging fiber runs under GitHub Actions: `CurrentRuntimeEnv`, read from the fiber's own context (a
|
|
19
|
+
* `Logger` callback is synchronous and cannot `yield*`), says so. Absent, no.
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
const underActionsIn = (fiber) => {
|
|
24
|
+
const runtime = Context.getOption(fiber.context, CurrentRuntimeEnv);
|
|
25
|
+
return Option.contains(Option.flatMap(runtime, (env) => env.ci), "github-actions");
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The string parts of a log message, sanitised: what a custom `render` receives, so it paints over clean input.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
const sanitizeParts = (message) => {
|
|
33
|
+
if (typeof message === "string") return sanitize(message);
|
|
34
|
+
if (Array.isArray(message)) return message.map((part) => typeof part === "string" ? sanitize(part) : part);
|
|
35
|
+
return message;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Neutralize the `##[` the runner's legacy parser finds anywhere in a line, inside an NDJSON record, as the JSON escape
|
|
39
|
+
* `##[`: it decodes to the identical text, so the record loses nothing. A `##[` can only sit in a string there.
|
|
40
|
+
*
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
const neutralizeJson = (line) => line.replaceAll("##[", "#\\u0023[");
|
|
44
|
+
|
|
45
|
+
//#endregion
|
|
46
|
+
export { TrustedLine, neutralizeJson, sanitizeParts, underActionsIn };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { flatten, paintSpans } from "./layout.js";
|
|
2
|
+
import { renderDoc, showsSuffix, targetText } from "./renderDoc.js";
|
|
3
|
+
|
|
4
|
+
//#region src/internal/renderAnsi.ts
|
|
5
|
+
/**
|
|
6
|
+
* Inline content as styled spans: code painted `accent` with no backticks, and links kept on their spans so that
|
|
7
|
+
* painting wraps them through `ctx.link`.
|
|
8
|
+
*
|
|
9
|
+
* When `ctx.link` does not make a hyperlink (it returns the label unchanged), the target would be lost, so it follows
|
|
10
|
+
* the label in parentheses, muted, unless the label already is the target. That is decided here, before layout, so
|
|
11
|
+
* widths and wrapping count it.
|
|
12
|
+
*/
|
|
13
|
+
const inline = (inlines, ctx) => {
|
|
14
|
+
const flat = flatten(inlines, ctx);
|
|
15
|
+
const out = [];
|
|
16
|
+
let i = 0;
|
|
17
|
+
while (i < flat.length) {
|
|
18
|
+
const link = flat[i].link;
|
|
19
|
+
let label = "";
|
|
20
|
+
do {
|
|
21
|
+
const span = flat[i];
|
|
22
|
+
label += span.text;
|
|
23
|
+
out.push(span.code === true && span.token === void 0 ? {
|
|
24
|
+
...span,
|
|
25
|
+
token: "accent"
|
|
26
|
+
} : span);
|
|
27
|
+
i++;
|
|
28
|
+
} while (link !== void 0 && i < flat.length && flat[i].link === link);
|
|
29
|
+
if (link !== void 0 && ctx.link(link, label) === label) {
|
|
30
|
+
const target = targetText(link, ctx);
|
|
31
|
+
if (showsSuffix(flat[i - 1].suffix, label, target)) out.push({
|
|
32
|
+
text: ` (${target})`,
|
|
33
|
+
token: "muted"
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
};
|
|
39
|
+
const ansi = {
|
|
40
|
+
inline,
|
|
41
|
+
finish: (line, ctx) => paintSpans(line, ctx)
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Render a document for a person: the layout of plain text, painted with the context's tokens and linked through its
|
|
45
|
+
* `link` function.
|
|
46
|
+
*
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
const renderAnsi = (doc, ctx) => renderDoc(doc, ctx, ansi);
|
|
50
|
+
|
|
51
|
+
//#endregion
|
|
52
|
+
export { renderAnsi };
|