@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.
Files changed (92) hide show
  1. package/Cancelled.js +44 -0
  2. package/CliAudience.js +178 -0
  3. package/CliColor.js +13 -19
  4. package/CliEnv.js +89 -0
  5. package/CliExit.js +1 -1
  6. package/CliFailure.js +302 -0
  7. package/CliInteractive.js +71 -0
  8. package/CliLinks.js +154 -0
  9. package/CliLog.js +346 -0
  10. package/CliLogger.js +34 -33
  11. package/CliMessage.js +80 -0
  12. package/CliPrompt.js +104 -0
  13. package/CliRuntime.js +110 -54
  14. package/CliTest.js +16 -0
  15. package/CliTheme.js +141 -0
  16. package/ConfigIssueRenderer.js +14 -33
  17. package/Doc.js +536 -0
  18. package/Fmt.js +133 -0
  19. package/GithubAnnotation.js +40 -0
  20. package/Glyphs.js +83 -0
  21. package/NotInteractive.js +42 -0
  22. package/README.md +145 -131
  23. package/Render.js +255 -0
  24. package/SchemaIssueRenderer.js +7 -10
  25. package/Status.js +166 -0
  26. package/TestTerminal.js +80 -0
  27. package/Token.js +69 -0
  28. package/index.d.ts +3089 -169
  29. package/index.js +19 -1
  30. package/internal/ansi.js +230 -0
  31. package/internal/autoFormat.js +34 -0
  32. package/internal/canPrompt.js +15 -0
  33. package/internal/counts.js +84 -0
  34. package/internal/diagnostics.js +32 -0
  35. package/internal/displayWidth.js +35 -0
  36. package/internal/failureTarget.js +195 -0
  37. package/internal/fallbackAnswer.js +18 -0
  38. package/internal/fileSink.js +62 -0
  39. package/internal/format.js +62 -7
  40. package/internal/layout.js +250 -0
  41. package/internal/linkScheme.js +30 -0
  42. package/internal/linkTarget.js +50 -0
  43. package/internal/logSafety.js +46 -0
  44. package/internal/renderAnsi.js +52 -0
  45. package/internal/renderDoc.js +320 -0
  46. package/internal/renderGithubLog.js +46 -0
  47. package/internal/renderMarkdown.js +368 -0
  48. package/internal/renderPlain.js +50 -0
  49. package/internal/scanAudience.js +106 -0
  50. package/internal/splitFrame.js +56 -0
  51. package/internal/wizardGate.js +18 -0
  52. package/package.json +40 -5
  53. package/testing.d.ts +88 -2
  54. package/testing.js +2 -1
  55. package/ui/CliUi.js +432 -0
  56. package/ui/CliUiLive.js +446 -0
  57. package/ui/Confirm.js +245 -0
  58. package/ui/DocView.js +74 -0
  59. package/ui/KeyHelp.js +62 -0
  60. package/ui/KeyTable.js +199 -0
  61. package/ui/MultiSelect.js +260 -0
  62. package/ui/Select.js +230 -0
  63. package/ui/Tabs.js +202 -0
  64. package/ui/TextInput.js +290 -0
  65. package/ui/Toggle.js +32 -0
  66. package/ui/UiKey.js +44 -0
  67. package/ui/UiProvider.js +60 -0
  68. package/ui/UiStreams.js +18 -0
  69. package/ui/UiTheme.js +119 -0
  70. package/ui/Viewport.js +204 -0
  71. package/ui/internal/ErrorBoundary.js +30 -0
  72. package/ui/internal/Holder.js +74 -0
  73. package/ui/internal/ScreenContext.js +52 -0
  74. package/ui/internal/UiProviders.js +21 -0
  75. package/ui/internal/ink.js +122 -0
  76. package/ui/internal/inkChalk.js +58 -0
  77. package/ui/internal/inkConsole.js +146 -0
  78. package/ui/internal/lazyView.js +74 -0
  79. package/ui/internal/lineText.js +19 -0
  80. package/ui/internal/mountPermit.js +16 -0
  81. package/ui/internal/perfDrain.js +33 -0
  82. package/ui/internal/processStreams.js +19 -0
  83. package/ui/internal/renderOptions.js +13 -0
  84. package/ui/testing/CliUiTest.js +760 -0
  85. package/ui/testing/fakeStreams.js +79 -0
  86. package/ui/testing/terminalModel.js +59 -0
  87. package/ui-testing-serializer.d.ts +14 -0
  88. package/ui-testing-serializer.js +33 -0
  89. package/ui-testing.d.ts +527 -0
  90. package/ui-testing.js +3 -0
  91. package/ui.d.ts +1790 -0
  92. 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 };
@@ -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 lines = formatter(issue).issues.map((entry) => {
43
- const path = (entry.path ?? []).map(String).join(".");
44
- return path === "" ? entry.message : `${entry.message} at ${path}`;
45
- });
46
- return [...new Set(lines)];
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 };