cli-kiss 0.2.4 → 0.2.6
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/dist/index.d.ts +159 -167
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/docs/.vitepress/config.mts +3 -2
- package/docs/.vitepress/theme/style.css +6 -2
- package/docs/guide/01_getting_started.md +12 -13
- package/docs/guide/02_commands.md +12 -29
- package/docs/guide/03_options.md +16 -25
- package/docs/guide/04_positionals.md +45 -55
- package/docs/guide/05_input_types.md +134 -0
- package/docs/guide/06_run_as_cli.md +143 -0
- package/docs/index.md +3 -2
- package/docs/public/favicon.ico +0 -0
- package/docs/public/hero.png +0 -0
- package/package.json +1 -1
- package/src/index.ts +0 -2
- package/src/lib/Command.ts +14 -35
- package/src/lib/Operation.ts +13 -4
- package/src/lib/Option.ts +118 -162
- package/src/lib/Positional.ts +37 -62
- package/src/lib/Reader.ts +3 -3
- package/src/lib/Run.ts +76 -49
- package/src/lib/Type.ts +227 -143
- package/src/lib/Typo.ts +55 -23
- package/src/lib/Usage.ts +30 -45
- package/tests/unit.Reader.parsings.ts +50 -0
- package/tests/unit.command.execute.ts +13 -13
- package/tests/unit.command.usage.ts +60 -54
- package/tests/unit.runner.colors.ts +199 -0
- package/tests/unit.runner.cycle.ts +69 -55
- package/tests/unit.runner.errors.ts +12 -20
- package/docs/guide/05_types.md +0 -132
- package/docs/guide/06_run.md +0 -160
package/src/lib/Type.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { statSync } from "fs";
|
|
1
2
|
import {
|
|
2
3
|
TypoError,
|
|
3
4
|
TypoString,
|
|
@@ -8,17 +9,17 @@ import {
|
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Decodes a raw CLI string into a typed value.
|
|
11
|
-
* A pair of a human-readable `content` name
|
|
12
|
+
* A pair of a human-readable `content` name and a `decoder` function.
|
|
12
13
|
*
|
|
13
|
-
* Built-in: {@link
|
|
14
|
-
* {@link typeInteger}, {@link
|
|
15
|
-
* Composite: {@link
|
|
14
|
+
* Built-in: {@link type}, {@link typeBoolean}, {@link typeNumber},
|
|
15
|
+
* {@link typeInteger}, {@link typeDatetime}, {@link typeUrl}.
|
|
16
|
+
* Composite: {@link typeChoice}, {@link typeConverted}, {@link typeTuple}, {@link typeList}.
|
|
16
17
|
*
|
|
17
18
|
* @typeParam Value - Type produced by the decoder.
|
|
18
19
|
*/
|
|
19
20
|
export type Type<Value> = {
|
|
20
21
|
/**
|
|
21
|
-
* Human-readable name shown in help and errors (e.g. `"
|
|
22
|
+
* Human-readable name shown in help and errors (e.g. `"name"`, `"number"`).
|
|
22
23
|
*/
|
|
23
24
|
content: string;
|
|
24
25
|
/**
|
|
@@ -37,34 +38,32 @@ export type Type<Value> = {
|
|
|
37
38
|
*
|
|
38
39
|
* @example
|
|
39
40
|
* ```ts
|
|
40
|
-
* typeBoolean.decoder("true") // → true
|
|
41
|
-
* typeBoolean.decoder("yes") // → true
|
|
42
|
-
* typeBoolean.decoder("y") // → true
|
|
43
|
-
* typeBoolean.decoder("false") // → false
|
|
44
|
-
* typeBoolean.decoder("no") // → false
|
|
45
|
-
* typeBoolean.decoder("n") // → false
|
|
41
|
+
* typeBoolean("flag").decoder("true") // → true
|
|
42
|
+
* typeBoolean("flag").decoder("yes") // → true
|
|
43
|
+
* typeBoolean("flag").decoder("y") // → true
|
|
44
|
+
* typeBoolean("flag").decoder("false") // → false
|
|
45
|
+
* typeBoolean("flag").decoder("no") // → false
|
|
46
|
+
* typeBoolean("flag").decoder("n") // → false
|
|
46
47
|
* ```
|
|
47
48
|
*/
|
|
48
|
-
export
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const booleanValuesTrue = new Set(["true", "yes", "on", "1", "y", "t"]);
|
|
67
|
-
const booleanValuesFalse = new Set(["false", "no", "off", "0", "n", "f"]);
|
|
49
|
+
export function typeBoolean(name?: string): Type<boolean> {
|
|
50
|
+
return {
|
|
51
|
+
content: name ?? "boolean",
|
|
52
|
+
decoder(input: string) {
|
|
53
|
+
const lower = input.toLowerCase();
|
|
54
|
+
if (typeBooleanValuesTrue.has(lower)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if (typeBooleanValuesFalse.has(lower)) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
throwInvalidValue("a boolean", input);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const typeBooleanValuesTrue = new Set(["true", "yes", "on", "y"]);
|
|
66
|
+
export const typeBooleanValuesFalse = new Set(["false", "no", "off", "n"]);
|
|
68
67
|
|
|
69
68
|
/**
|
|
70
69
|
* Parses a date/time string via `Date.parse`.
|
|
@@ -72,60 +71,54 @@ const booleanValuesFalse = new Set(["false", "no", "off", "0", "n", "f"]);
|
|
|
72
71
|
*
|
|
73
72
|
* @example
|
|
74
73
|
* ```ts
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
74
|
+
* typeDatetime("my-datetime").decoder("2024-01-15") // → Date object for 2024-01-15
|
|
75
|
+
* typeDatetime("my-datetime").decoder("2024-01-15T13:45:30Z") // → Date object for 2024-01-15 13:45:30 UTC
|
|
76
|
+
* typeDatetime("my-datetime").decoder("not a date") // throws TypoError
|
|
78
77
|
* ```
|
|
79
78
|
*/
|
|
80
|
-
export
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
79
|
+
export function typeDatetime(name?: string): Type<Date> {
|
|
80
|
+
return {
|
|
81
|
+
content: name ?? "datetime",
|
|
82
|
+
decoder(input: string) {
|
|
83
|
+
try {
|
|
84
|
+
const timestampMs = Date.parse(input);
|
|
85
|
+
if (isNaN(timestampMs)) {
|
|
86
|
+
throw new Error();
|
|
87
|
+
}
|
|
88
|
+
return new Date(timestampMs);
|
|
89
|
+
} catch {
|
|
90
|
+
throwInvalidValue("a valid ISO_8601 datetime", input);
|
|
87
91
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
new TypoText(
|
|
92
|
-
new TypoString(`Not a valid ISO_8601: `),
|
|
93
|
-
new TypoString(`"${input}"`, typoStyleQuote),
|
|
94
|
-
),
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
};
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
99
95
|
|
|
100
96
|
/**
|
|
101
97
|
* Parses a string to `number` via `Number()`; `NaN` throws {@link TypoError}.
|
|
102
98
|
*
|
|
103
99
|
* @example
|
|
104
100
|
* ```ts
|
|
105
|
-
* typeNumber.decoder("3.14") // → 3.14
|
|
106
|
-
* typeNumber.decoder("-1") // → -1
|
|
107
|
-
* typeNumber.decoder("hello") // throws
|
|
101
|
+
* typeNumber("my-number").decoder("3.14") // → 3.14
|
|
102
|
+
* typeNumber("my-number").decoder("-1") // → -1
|
|
103
|
+
* typeNumber("my-number").decoder("hello") // throws
|
|
108
104
|
* ```
|
|
109
105
|
*/
|
|
110
|
-
export
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
106
|
+
export function typeNumber(name?: string): Type<number> {
|
|
107
|
+
return {
|
|
108
|
+
content: name ?? "number",
|
|
109
|
+
decoder(input: string) {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = Number(input);
|
|
112
|
+
if (isNaN(parsed)) {
|
|
113
|
+
throw new Error();
|
|
114
|
+
}
|
|
115
|
+
return parsed;
|
|
116
|
+
} catch {
|
|
117
|
+
throwInvalidValue("a number", input);
|
|
117
118
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
new TypoText(
|
|
122
|
-
new TypoString(`Unable to parse: `),
|
|
123
|
-
new TypoString(`"${input}"`, typoStyleQuote),
|
|
124
|
-
),
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
},
|
|
128
|
-
};
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
129
122
|
|
|
130
123
|
/**
|
|
131
124
|
* Parses an integer string to `bigint` via `BigInt()`.
|
|
@@ -133,26 +126,23 @@ export const typeNumber: Type<number> = {
|
|
|
133
126
|
*
|
|
134
127
|
* @example
|
|
135
128
|
* ```ts
|
|
136
|
-
* typeInteger.decoder("42") // → 42n
|
|
137
|
-
* typeInteger.decoder("3.14") // throws
|
|
138
|
-
* typeInteger.decoder("abc") // throws
|
|
129
|
+
* typeInteger("my-integer").decoder("42") // → 42n
|
|
130
|
+
* typeInteger("my-integer").decoder("3.14") // throws
|
|
131
|
+
* typeInteger("my-integer").decoder("abc") // throws
|
|
139
132
|
* ```
|
|
140
133
|
*/
|
|
141
|
-
export
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
},
|
|
155
|
-
};
|
|
134
|
+
export function typeInteger(name?: string): Type<bigint> {
|
|
135
|
+
return {
|
|
136
|
+
content: name ?? "integer",
|
|
137
|
+
decoder(input: string) {
|
|
138
|
+
try {
|
|
139
|
+
return BigInt(input);
|
|
140
|
+
} catch {
|
|
141
|
+
throwInvalidValue("an integer", input);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
156
146
|
|
|
157
147
|
/**
|
|
158
148
|
* Parses an absolute URL string to a `URL` object.
|
|
@@ -160,41 +150,38 @@ export const typeInteger: Type<bigint> = {
|
|
|
160
150
|
*
|
|
161
151
|
* @example
|
|
162
152
|
* ```ts
|
|
163
|
-
* typeUrl.decoder("https://example.com") // → URL { href: "https://example.com/", ... }
|
|
164
|
-
* typeUrl.decoder("not-a-url") // throws
|
|
153
|
+
* typeUrl("my-url").decoder("https://example.com") // → URL { href: "https://example.com/", ... }
|
|
154
|
+
* typeUrl("my-url").decoder("not-a-url") // throws
|
|
165
155
|
* ```
|
|
166
156
|
*/
|
|
167
|
-
export
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
},
|
|
181
|
-
};
|
|
157
|
+
export function typeUrl(name?: string): Type<URL> {
|
|
158
|
+
return {
|
|
159
|
+
content: name ?? "url",
|
|
160
|
+
decoder(input: string) {
|
|
161
|
+
try {
|
|
162
|
+
return new URL(input);
|
|
163
|
+
} catch {
|
|
164
|
+
throwInvalidValue("an URL", input);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
182
169
|
|
|
183
170
|
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
171
|
+
* A named type that accepts any string as input.
|
|
172
|
+
* @param name - Name shown in help and errors (e.g. `"my-value"`).
|
|
186
173
|
* @example
|
|
187
174
|
* ```ts
|
|
188
|
-
*
|
|
189
|
-
*
|
|
175
|
+
* type("greeting").decoder("hello") // → "hello"
|
|
176
|
+
* type("greeting").decoder("") // → ""
|
|
190
177
|
* ```
|
|
191
178
|
*/
|
|
192
|
-
export
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
}
|
|
179
|
+
export function type(name?: string): Type<string> {
|
|
180
|
+
return {
|
|
181
|
+
content: name ?? "string",
|
|
182
|
+
decoder: (input: string) => input,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
198
185
|
|
|
199
186
|
/**
|
|
200
187
|
* Chains `before`'s decoder with an `after` transformation.
|
|
@@ -203,33 +190,30 @@ export const typeString: Type<string> = {
|
|
|
203
190
|
* @typeParam Before - Intermediate type from `before.decoder`.
|
|
204
191
|
* @typeParam After - Final type from `after.decoder`.
|
|
205
192
|
*
|
|
206
|
-
* @param
|
|
207
|
-
* @param
|
|
208
|
-
* @param
|
|
209
|
-
* @param after.decoder - Converts a `Before` value to `After`.
|
|
193
|
+
* @param name - Name shown in help and errors (e.g. `"my-value"`).
|
|
194
|
+
* @param before - Base type to decode the raw string.
|
|
195
|
+
* @param mapper - Transforms `before`'s output to the final value; errors are wrapped with context.
|
|
210
196
|
* @returns A {@link Type}`<After>`.
|
|
211
197
|
*
|
|
212
198
|
* @example
|
|
213
199
|
* ```ts
|
|
214
|
-
* const typePort =
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* if (n < 1 || n > 65535) throw new Error("Out of range");
|
|
218
|
-
* return n;
|
|
219
|
-
* },
|
|
200
|
+
* const typePort = typeConverted("port", typeNumber(), (n) => {
|
|
201
|
+
* if (n < 1 || n > 65535) throw new Error("Out of range");
|
|
202
|
+
* return n;
|
|
220
203
|
* });
|
|
221
204
|
* // "--port 8080" → 8080
|
|
222
205
|
* // "--port 99999" → TypoError: --port: <PORT>: Port: Out of range
|
|
223
206
|
* ```
|
|
224
207
|
*/
|
|
225
|
-
export function
|
|
208
|
+
export function typeConverted<Before, After>(
|
|
209
|
+
name: string,
|
|
226
210
|
before: Type<Before>,
|
|
227
|
-
|
|
211
|
+
mapper: (value: Before) => After,
|
|
228
212
|
): Type<After> {
|
|
229
213
|
return {
|
|
230
|
-
content:
|
|
214
|
+
content: name,
|
|
231
215
|
decoder: (input: string) => {
|
|
232
|
-
return
|
|
216
|
+
return mapper(
|
|
233
217
|
TypoError.tryWithContext(
|
|
234
218
|
() => before.decoder(input),
|
|
235
219
|
() =>
|
|
@@ -243,32 +227,123 @@ export function typeMapped<Before, After>(
|
|
|
243
227
|
};
|
|
244
228
|
}
|
|
245
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Adds a name to a {@link Type} for clearer error messages and help text.
|
|
232
|
+
*
|
|
233
|
+
* @param name - Name to use for the type.
|
|
234
|
+
* @param type - Base type to name.
|
|
235
|
+
* @returns A {@link Type} with the given name.
|
|
236
|
+
*/
|
|
237
|
+
export function typeRenamed<Value>(
|
|
238
|
+
type: Type<Value>,
|
|
239
|
+
name: string,
|
|
240
|
+
): Type<Value> {
|
|
241
|
+
return {
|
|
242
|
+
content: name,
|
|
243
|
+
decoder: (input: string) => {
|
|
244
|
+
return TypoError.tryWithContext(
|
|
245
|
+
() => type.decoder(input),
|
|
246
|
+
() =>
|
|
247
|
+
new TypoText(
|
|
248
|
+
new TypoString("from: "),
|
|
249
|
+
new TypoString(type.content, typoStyleLogic),
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Creates a {@link Type} for filesystem paths with optional existence checks.
|
|
258
|
+
* @param checks - Optional checks for path existence and type (file/directory).
|
|
259
|
+
* @returns A {@link Type}`<string>` representing the path.
|
|
260
|
+
*/
|
|
261
|
+
export function typePath(
|
|
262
|
+
name?: string,
|
|
263
|
+
checks?: { checkSyncExistAs?: "file" | "directory" | "anything" },
|
|
264
|
+
): Type<string> {
|
|
265
|
+
return {
|
|
266
|
+
content: name ?? "path",
|
|
267
|
+
decoder(input: string) {
|
|
268
|
+
if (input.length === 0) {
|
|
269
|
+
throw new Error(`Path cannot be empty`);
|
|
270
|
+
}
|
|
271
|
+
if (input.includes("\0")) {
|
|
272
|
+
throw new Error(`Path cannot contain null characters`);
|
|
273
|
+
}
|
|
274
|
+
if (checks?.checkSyncExistAs !== undefined) {
|
|
275
|
+
function safeStatSync(path: string) {
|
|
276
|
+
try {
|
|
277
|
+
return statSync(path);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
throw new TypoError(
|
|
280
|
+
new TypoText(
|
|
281
|
+
new TypoString(`Path does not exist: `),
|
|
282
|
+
new TypoString(`"${path}"`, typoStyleQuote),
|
|
283
|
+
),
|
|
284
|
+
error,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const stats = safeStatSync(input);
|
|
289
|
+
const preview = stats.isDirectory()
|
|
290
|
+
? "directory"
|
|
291
|
+
: stats.isFile()
|
|
292
|
+
? "file"
|
|
293
|
+
: "unknown";
|
|
294
|
+
if (checks.checkSyncExistAs === "file" && !stats.isFile()) {
|
|
295
|
+
throw new TypoError(
|
|
296
|
+
new TypoText(
|
|
297
|
+
new TypoString(`Expected a file but found: ${preview}: `),
|
|
298
|
+
new TypoString(`"${input}"`, typoStyleQuote),
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (checks.checkSyncExistAs === "directory" && !stats.isDirectory()) {
|
|
303
|
+
throw new TypoError(
|
|
304
|
+
new TypoText(
|
|
305
|
+
new TypoString(`Expected a directory but found: ${preview}: `),
|
|
306
|
+
new TypoString(`"${input}"`, typoStyleQuote),
|
|
307
|
+
),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return input;
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
246
316
|
/**
|
|
247
317
|
* Creates a {@link Type}`<string>` that only accepts a fixed set of values.
|
|
248
318
|
* Out-of-set inputs throw {@link TypoError} listing up to 5 valid options.
|
|
249
319
|
*
|
|
250
|
-
* @param
|
|
320
|
+
* @param name - Name shown in help and errors.
|
|
251
321
|
* @param values - Ordered list of accepted values.
|
|
252
322
|
* @returns A {@link Type}`<string>`.
|
|
253
323
|
*
|
|
254
324
|
* @example
|
|
255
325
|
* ```ts
|
|
256
|
-
* const typeEnv =
|
|
326
|
+
* const typeEnv = typeChoice("environment", ["dev", "staging", "prod"]);
|
|
257
327
|
* typeEnv.decoder("prod") // → "prod"
|
|
258
328
|
* typeEnv.decoder("unknown") // throws TypoError: Invalid value: "unknown" (expected one of: "dev" | "staging" | "prod")
|
|
259
329
|
* ```
|
|
260
330
|
*/
|
|
261
|
-
export function
|
|
262
|
-
|
|
331
|
+
export function typeChoice<const Value extends string>(
|
|
332
|
+
name: string,
|
|
263
333
|
values: Array<Value>,
|
|
334
|
+
caseSensitive: boolean = false,
|
|
264
335
|
): Type<Value> {
|
|
336
|
+
const normalize = caseSensitive
|
|
337
|
+
? (s: string) => s
|
|
338
|
+
: (s: string) => s.toLowerCase();
|
|
339
|
+
const valueMap = new Map(values.map((value) => [normalize(value), value]));
|
|
265
340
|
return {
|
|
266
|
-
content:
|
|
341
|
+
content: name,
|
|
267
342
|
decoder(input: string) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
343
|
+
const normalized = normalize(input);
|
|
344
|
+
const original = valueMap.get(normalized);
|
|
345
|
+
if (original !== undefined) {
|
|
346
|
+
return original;
|
|
272
347
|
}
|
|
273
348
|
const valuesPreview = [];
|
|
274
349
|
for (const value of values) {
|
|
@@ -306,7 +381,7 @@ export function typeOneOf<const Value extends string>(
|
|
|
306
381
|
*
|
|
307
382
|
* @example
|
|
308
383
|
* ```ts
|
|
309
|
-
* const typePoint = typeTuple([typeNumber, typeNumber]);
|
|
384
|
+
* const typePoint = typeTuple([typeNumber("x"), typeNumber("y")]);
|
|
310
385
|
* typePoint.decoder("3.14,2.71") // → [3.14, 2.71]
|
|
311
386
|
* typePoint.decoder("1,2,3") // → [1, 2]
|
|
312
387
|
* typePoint.decoder("x,2") // throws TypoError: at 0: Number: Unable to parse: "x"
|
|
@@ -363,7 +438,7 @@ export function typeTuple<const Elements extends Array<any>>(
|
|
|
363
438
|
* typeNumbers.decoder("1,2,3") // → [1, 2, 3]
|
|
364
439
|
* typeNumbers.decoder("1,x,3") // throws TypoError: at 1: Number: Unable to parse: "x"
|
|
365
440
|
*
|
|
366
|
-
* const typePaths = typeList(
|
|
441
|
+
* const typePaths = typeList(typePath(), ":");
|
|
367
442
|
* typePaths.decoder("/usr/bin:/usr/local/bin") // → ["/usr/bin", "/usr/local/bin"]
|
|
368
443
|
* ```
|
|
369
444
|
*/
|
|
@@ -388,3 +463,12 @@ export function typeList<Value>(
|
|
|
388
463
|
},
|
|
389
464
|
};
|
|
390
465
|
}
|
|
466
|
+
|
|
467
|
+
function throwInvalidValue(kind: string, input: string): never {
|
|
468
|
+
throw new TypoError(
|
|
469
|
+
new TypoText(
|
|
470
|
+
new TypoString(`Not ${kind}: `),
|
|
471
|
+
new TypoString(`"${input}"`, typoStyleQuote),
|
|
472
|
+
),
|
|
473
|
+
);
|
|
474
|
+
}
|
package/src/lib/Typo.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { typeBooleanValuesFalse } from "./Type";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Color names for terminal styling, used by {@link TypoStyle}.
|
|
3
5
|
* `dark*` = standard ANSI (30–37); `bright*` = high-intensity (90–97).
|
|
@@ -25,6 +27,10 @@ export type TypoColor =
|
|
|
25
27
|
* All fields are optional; ignored entirely in `"none"` mode.
|
|
26
28
|
*/
|
|
27
29
|
export type TypoStyle = {
|
|
30
|
+
/**
|
|
31
|
+
* Letter case.
|
|
32
|
+
*/
|
|
33
|
+
case?: "upper" | "lower";
|
|
28
34
|
/**
|
|
29
35
|
* Foreground (text) color.
|
|
30
36
|
*/
|
|
@@ -228,7 +234,7 @@ export class TypoGrid {
|
|
|
228
234
|
* Renders as an array of styled, column-padded strings.
|
|
229
235
|
*
|
|
230
236
|
* @param typoSupport - Rendering mode.
|
|
231
|
-
* @returns
|
|
237
|
+
* @returns Array of styled strings.
|
|
232
238
|
*/
|
|
233
239
|
computeStyledLines(typoSupport: TypoSupport): Array<string> {
|
|
234
240
|
const widths = new Array<number>();
|
|
@@ -346,36 +352,48 @@ export class TypoSupport {
|
|
|
346
352
|
}
|
|
347
353
|
/**
|
|
348
354
|
* Deterministic textual styling for snapshot tests.
|
|
349
|
-
* Style flags appear as suffixes: `{text}@color`, `{text}+` (bold), `{text}-` (dim),
|
|
350
|
-
* `{text}*` (italic), `{text}_` (underline), `{text}~` (strikethrough).
|
|
351
355
|
*/
|
|
352
356
|
static mock(): TypoSupport {
|
|
353
357
|
return new TypoSupport("mock");
|
|
354
358
|
}
|
|
355
359
|
/**
|
|
356
|
-
* Auto-detects styling mode from the process environment.
|
|
357
|
-
* `FORCE_COLOR=0` / `NO_COLOR` → none; `FORCE_COLOR` (truthy) / `isTTY` → tty; else → none.
|
|
358
|
-
* Falls back to none if `process` is unavailable.
|
|
360
|
+
* Auto-detects styling mode from the process environment on best-effort basis.
|
|
359
361
|
*/
|
|
360
|
-
static
|
|
361
|
-
|
|
362
|
+
static inferFromEnv(): TypoSupport {
|
|
363
|
+
/*
|
|
364
|
+
console.warn({
|
|
365
|
+
no: readEnvVar("NO_COLOR"),
|
|
366
|
+
force: readEnvVar("FORCE_COLOR"),
|
|
367
|
+
mock: readEnvVar("MOCK_COLOR"),
|
|
368
|
+
term: readEnvVar("TERM"),
|
|
369
|
+
tty: process.stdout.isTTY,
|
|
370
|
+
});
|
|
371
|
+
*/
|
|
372
|
+
if (!process || !process.env || !process.stdout) {
|
|
362
373
|
return TypoSupport.none();
|
|
363
374
|
}
|
|
364
|
-
if (
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
375
|
+
if (readEnvVar("NO_COLOR")) {
|
|
376
|
+
return TypoSupport.none();
|
|
377
|
+
}
|
|
378
|
+
const envForceColor = readEnvVar("FORCE_COLOR");
|
|
379
|
+
if (envForceColor === "0") {
|
|
380
|
+
return TypoSupport.none();
|
|
381
|
+
}
|
|
382
|
+
if (envForceColor !== undefined) {
|
|
383
|
+
if (!typeBooleanValuesFalse.has(envForceColor.toLowerCase())) {
|
|
369
384
|
return TypoSupport.tty();
|
|
370
385
|
}
|
|
371
|
-
if ("NO_COLOR" in process.env) {
|
|
372
|
-
return TypoSupport.none();
|
|
373
|
-
}
|
|
374
386
|
}
|
|
375
|
-
if (
|
|
376
|
-
return TypoSupport.
|
|
387
|
+
if (readEnvVar("MOCK_COLOR")) {
|
|
388
|
+
return TypoSupport.mock();
|
|
377
389
|
}
|
|
378
|
-
|
|
390
|
+
if (!process.stdout.isTTY) {
|
|
391
|
+
return TypoSupport.none();
|
|
392
|
+
}
|
|
393
|
+
if (readEnvVar("TERM")?.toLowerCase() === "dumb") {
|
|
394
|
+
return TypoSupport.none();
|
|
395
|
+
}
|
|
396
|
+
return TypoSupport.tty();
|
|
379
397
|
}
|
|
380
398
|
/**
|
|
381
399
|
* Applies `typoStyle` to `value` according to the current mode.
|
|
@@ -385,8 +403,15 @@ export class TypoSupport {
|
|
|
385
403
|
* @returns Styled string.
|
|
386
404
|
*/
|
|
387
405
|
computeStyledString(value: string, typoStyle: TypoStyle): string {
|
|
406
|
+
let styledValue = value;
|
|
407
|
+
if (typoStyle.case === "upper") {
|
|
408
|
+
styledValue = styledValue.toUpperCase();
|
|
409
|
+
}
|
|
410
|
+
if (typoStyle.case === "lower") {
|
|
411
|
+
styledValue = styledValue.toLowerCase();
|
|
412
|
+
}
|
|
388
413
|
if (this.#kind === "none") {
|
|
389
|
-
return
|
|
414
|
+
return styledValue;
|
|
390
415
|
}
|
|
391
416
|
if (this.#kind === "tty") {
|
|
392
417
|
const fgColorCode = typoStyle.fgColor
|
|
@@ -402,12 +427,12 @@ export class TypoSupport {
|
|
|
402
427
|
const strikethroughCode = typoStyle.strikethrough
|
|
403
428
|
? ttyCodeStrikethrough
|
|
404
429
|
: "";
|
|
405
|
-
return `${fgColorCode}${bgColorCode}${boldCode}${dimCode}${italicCode}${underlineCode}${strikethroughCode}${
|
|
430
|
+
return `${fgColorCode}${bgColorCode}${boldCode}${dimCode}${italicCode}${underlineCode}${strikethroughCode}${styledValue}${ttyCodeReset}`;
|
|
406
431
|
}
|
|
407
432
|
if (this.#kind === "mock") {
|
|
408
433
|
const fgColorPart = typoStyle.fgColor
|
|
409
|
-
? `{${
|
|
410
|
-
:
|
|
434
|
+
? `{${styledValue}}@${typoStyle.fgColor}`
|
|
435
|
+
: styledValue;
|
|
411
436
|
const bgColorPart = typoStyle.bgColor
|
|
412
437
|
? `{${fgColorPart}}#${typoStyle.bgColor}`
|
|
413
438
|
: fgColorPart;
|
|
@@ -484,3 +509,10 @@ const ttyCodeBgColors: Record<TypoColor, string> = {
|
|
|
484
509
|
brightCyan: "\x1b[106m",
|
|
485
510
|
brightWhite: "\x1b[107m",
|
|
486
511
|
};
|
|
512
|
+
|
|
513
|
+
function readEnvVar(name: string) {
|
|
514
|
+
if (!(name in process.env)) {
|
|
515
|
+
return undefined;
|
|
516
|
+
}
|
|
517
|
+
return process.env[name];
|
|
518
|
+
}
|