@gunshi/plugin-renderer 0.37.2 → 1.0.0-beta.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/README.md +7 -3
- package/lib/index.d.ts +15 -3
- package/lib/index.js +221 -52
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
> usage renderer plugin for gunshi.
|
|
8
8
|
|
|
9
|
-
This plugin provides customizable rendering for CLI help messages, usage information, and validation errors. It automatically formats command descriptions, arguments, options, examples, and error messages in a consistent and readable format.
|
|
9
|
+
This plugin provides customizable rendering for CLI help messages, usage information, and validation errors. It automatically formats command descriptions, arguments, options, examples, and error messages in a consistent and readable format. Description columns are aligned by the width a terminal draws, including full-width names; emoji sequences joined with ZWJ may still differ by terminal.
|
|
10
10
|
|
|
11
11
|
## 💿 Installation
|
|
12
12
|
|
|
@@ -136,7 +136,9 @@ Available extensions:
|
|
|
136
136
|
|
|
137
137
|
- **`text<K>(key: K, values?: Record<string, unknown>): Promise<string>`**: Render text with optional i18n support. Handles built-in keys, argument descriptions, and custom keys intelligently.
|
|
138
138
|
|
|
139
|
-
- **`loadCommands<G>(): Promise<Command<G>[]>`**: Load and cache
|
|
139
|
+
- **`loadCommands<G>(): Promise<Command<G>[]>`**: Load and cache commands for rendering command lists. At the top level, the list also includes the CLI's entry command when plugins registered the subcommands without adding the entry to the registry. Results are cached after the first call for performance.
|
|
140
|
+
|
|
141
|
+
- **`localizeCommandDescription(cmd): Promise<string>`**: Resolve the description of a command in the command list. Localized when `@gunshi/plugin-i18n` is installed and the command has a resource of its own; otherwise `Command.description`.
|
|
140
142
|
|
|
141
143
|
### Usage Example
|
|
142
144
|
|
|
@@ -214,13 +216,15 @@ await cli(args, command, {
|
|
|
214
216
|
plugins: [
|
|
215
217
|
i18n({
|
|
216
218
|
locale: 'ja-JP',
|
|
217
|
-
resources // Uses built-in resources from `@gunshi/resources`
|
|
219
|
+
builtinResources: resources // Uses built-in resources from `@gunshi/resources`
|
|
218
220
|
}),
|
|
219
221
|
renderer() // Will use Japanese translations
|
|
220
222
|
]
|
|
221
223
|
})
|
|
222
224
|
```
|
|
223
225
|
|
|
226
|
+
The renderer imports its error guards from `@gunshi/plugin`, which bundles its own copy of the error classes. It still recognizes and localizes validation errors thrown by `gunshi` or `@gunshi/bone`. Keep `gunshi` and all `@gunshi/*` packages on the same version: when an older copy throws an argument validation error such as a missing required option, the renderer cannot recognize it and falls back to the English message.
|
|
227
|
+
|
|
224
228
|
### Custom Rendering
|
|
225
229
|
|
|
226
230
|
You can create custom plugins that use the renderer functions while adding your own branding or logic:
|
package/lib/index.d.ts
CHANGED
|
@@ -35,8 +35,8 @@ type CommonArgType = {
|
|
|
35
35
|
};
|
|
36
36
|
declare const COMMON_ARGS: CommonArgType;
|
|
37
37
|
declare const COMMAND_BUILTIN_RESOURCE_KEYS: readonly ["USAGE", "COMMAND", "SUBCOMMAND", "COMMANDS", "ARGUMENTS", "OPTIONS", "EXAMPLES", "FORMORE", "NEGATABLE", "DEFAULT", "CHOICES"];
|
|
38
|
-
declare const ARG_ERROR_RESOURCE_KEYS: readonly ["err:arg:required-option", "err:arg:required-positional", "err:arg:invalid-type", "err:arg:invalid-choice", "err:arg:custom-parse", "err:arg:unknown-option"];
|
|
39
|
-
declare const COMMAND_ERROR_RESOURCE_KEYS: readonly ["err:cmd:not-found"];
|
|
38
|
+
declare const ARG_ERROR_RESOURCE_KEYS: readonly ["err:arg:required-option", "err:arg:required-positional", "err:arg:invalid-type", "err:arg:invalid-choice", "err:arg:custom-parse", "err:arg:unknown-option", "err:arg:unexpected-value", "err:arg:missing-value", "err:arg:conflict", "err:arg:invalid-default"];
|
|
39
|
+
declare const COMMAND_ERROR_RESOURCE_KEYS: readonly ["err:cmd:not-found", "err:cmd:ambiguous", "err:cmd:inconsistent-options", "err:cmd:lazy-schema-mismatch"];
|
|
40
40
|
declare const SUGGESTION_ERROR_RESOURCE_KEYS: readonly ["err:suggestion:did-you-mean"];
|
|
41
41
|
//#endregion
|
|
42
42
|
//#region ../plugin-i18n/src/types.d.ts
|
|
@@ -161,9 +161,21 @@ interface UsageRendererExtension<G extends GunshiParams<any> = DefaultGunshiPara
|
|
|
161
161
|
*
|
|
162
162
|
* @typeParam G - A type extending {@link GunshiParams} to specify the shape of command parameters.
|
|
163
163
|
*
|
|
164
|
-
* @returns A list of commands loaded from the usage renderer plugin.
|
|
164
|
+
* @returns A list of commands loaded from the usage renderer plugin. At the top-level entry,
|
|
165
|
+
* the list includes the CLI entry command even when a plugin registered the other commands
|
|
166
|
+
* without adding the entry to `env.subCommands`.
|
|
165
167
|
*/
|
|
166
168
|
loadCommands: <G extends GunshiParams = DefaultGunshiParams>() => Promise<Command<G>[]>;
|
|
169
|
+
/**
|
|
170
|
+
* Resolve the description of a command in the command list.
|
|
171
|
+
*
|
|
172
|
+
* The description is localized when `@gunshi/plugin-i18n` is installed and the command has a
|
|
173
|
+
* resource of its own, and falls back to {@link Command.description} otherwise.
|
|
174
|
+
*
|
|
175
|
+
* @param cmd - A command from {@link UsageRendererExtension.loadCommands}.
|
|
176
|
+
* @returns The description to show for the command.
|
|
177
|
+
*/
|
|
178
|
+
localizeCommandDescription: (cmd: Command) => Promise<string>;
|
|
167
179
|
}
|
|
168
180
|
//#endregion
|
|
169
181
|
//#region src/header.d.ts
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ANONYMOUS_COMMAND_NAME, ArgsValidationErrorKeys, isArgsValidationError, isCommandNotFoundError, plugin } from "@gunshi/plugin";
|
|
2
|
-
//#region ../../node_modules/.pnpm/args-tokens@0.
|
|
1
|
+
import { ANONYMOUS_COMMAND_NAME, ArgsValidationErrorKeys, createCommandContext, isArgsValidationError, isCommandNotFoundError, isCommandResolutionError, plugin } from "@gunshi/plugin";
|
|
2
|
+
//#region ../../node_modules/.pnpm/args-tokens@1.0.0-beta.1/node_modules/args-tokens/lib/utils.js
|
|
3
3
|
/**
|
|
4
4
|
* Entry point of utils.
|
|
5
5
|
*
|
|
@@ -30,14 +30,20 @@ function kebabnize(str) {
|
|
|
30
30
|
* Check if the given command is a {@link LazyCommand}.
|
|
31
31
|
*
|
|
32
32
|
* @param cmd - A command to check
|
|
33
|
-
* @returns `true` if the command is a {@link LazyCommand}, otherwise `false
|
|
33
|
+
* @returns `true` if the command is a {@link LazyCommand}, otherwise `false`
|
|
34
|
+
*
|
|
35
|
+
* A lazy command is identified by its `commandName` property. The property may be
|
|
36
|
+
* `undefined` when the lazy command has no definition name.
|
|
34
37
|
*/
|
|
35
38
|
function isLazyCommand(cmd) {
|
|
36
|
-
return typeof cmd === "function" && "commandName" in cmd
|
|
39
|
+
return typeof cmd === "function" && "commandName" in cmd;
|
|
37
40
|
}
|
|
38
41
|
/**
|
|
39
42
|
* Resolve a lazy command to a {@link Command}.
|
|
40
43
|
*
|
|
44
|
+
* If the loader returns a command, the properties of the loaded command take precedence,
|
|
45
|
+
* and the properties that it does not define fall back to the definition that is given to `lazy`.
|
|
46
|
+
*
|
|
41
47
|
* @param cmd - A {@link Commandable} or {@link LazyCommand} to resolve
|
|
42
48
|
* @param name - Optional name of the command, if not provided, it will use the name from the command itself.
|
|
43
49
|
* @param needRunResolving - Whether to run the resolving function of the lazy command.
|
|
@@ -53,6 +59,8 @@ async function resolveLazyCommand(cmd, name, needRunResolving = false) {
|
|
|
53
59
|
examples: cmd.examples,
|
|
54
60
|
internal: cmd.internal,
|
|
55
61
|
entry: cmd.entry,
|
|
62
|
+
toKebab: cmd.toKebab,
|
|
63
|
+
rendering: cmd.rendering,
|
|
56
64
|
subCommands: cmd.subCommands
|
|
57
65
|
};
|
|
58
66
|
if ("resource" in cmd && cmd.resource) baseCommand.resource = cmd.resource;
|
|
@@ -62,13 +70,20 @@ async function resolveLazyCommand(cmd, name, needRunResolving = false) {
|
|
|
62
70
|
if (typeof loaded === "function") command.run = loaded;
|
|
63
71
|
else if (typeof loaded === "object") {
|
|
64
72
|
if (loaded.run == null) throw new TypeError(`'run' is required in command: ${cmd.name || name}`);
|
|
73
|
+
/**
|
|
74
|
+
* NOTE(kazupon): the loaded command wins, and the definition that is given to `lazy` is the fallback
|
|
75
|
+
* for what the loaded command does not define. A loader that returns `{ run }` keeps the whole definition,
|
|
76
|
+
* like a loader that returns the runner itself.
|
|
77
|
+
*/
|
|
65
78
|
command.run = loaded.run;
|
|
66
|
-
command.name = loaded.name;
|
|
67
|
-
command.description = loaded.description;
|
|
68
|
-
command.args = loaded.args;
|
|
69
|
-
command.examples = loaded.examples;
|
|
70
|
-
command.internal = loaded.internal;
|
|
71
|
-
command.entry = loaded.entry;
|
|
79
|
+
command.name = loaded.name ?? cmd.commandName;
|
|
80
|
+
command.description = loaded.description ?? cmd.description;
|
|
81
|
+
command.args = loaded.args ?? cmd.args;
|
|
82
|
+
command.examples = loaded.examples ?? cmd.examples;
|
|
83
|
+
command.internal = loaded.internal ?? cmd.internal;
|
|
84
|
+
command.entry = loaded.entry ?? cmd.entry;
|
|
85
|
+
command.toKebab = loaded.toKebab ?? cmd.toKebab;
|
|
86
|
+
command.rendering = loaded.rendering ?? cmd.rendering;
|
|
72
87
|
command.subCommands = loaded.subCommands || cmd.subCommands;
|
|
73
88
|
if ("resource" in loaded && loaded.resource) command.resource = loaded.resource;
|
|
74
89
|
} else throw new TypeError(`Cannot resolve command: ${cmd.name || name}`);
|
|
@@ -143,7 +158,14 @@ var en_US_default = {
|
|
|
143
158
|
"err:arg:invalid-choice": "Invalid value for {$displayName}: expected one of {$choices}",
|
|
144
159
|
"err:arg:custom-parse": "Invalid value for {$displayName}: {$reason}",
|
|
145
160
|
"err:arg:unknown-option": "Unknown option: {$rawName}",
|
|
161
|
+
"err:arg:unexpected-value": "Optional argument {$displayName} does not take a value",
|
|
162
|
+
"err:arg:missing-value": "Optional argument {$displayName} requires a value",
|
|
163
|
+
"err:arg:conflict": "{$displayName} conflicts with {$conflictDisplayName}",
|
|
164
|
+
"err:arg:invalid-default": "Invalid default for {$displayName}: {$actual} is not one of {$choices}",
|
|
146
165
|
"err:cmd:not-found": "Command not found: {$commandName}",
|
|
166
|
+
"err:cmd:ambiguous": "The command is ambiguous. Move options after the command name: {$candidatePaths}",
|
|
167
|
+
"err:cmd:inconsistent-options": "The options do not identify a consistent command. Move options after the command name.",
|
|
168
|
+
"err:cmd:lazy-schema-mismatch": "The lazy command definition does not match its loaded options. Declare leading options in the lazy definition or move options after the command name: {$commandPath}",
|
|
147
169
|
"err:suggestion:did-you-mean": "Did you mean {$name}?"
|
|
148
170
|
};
|
|
149
171
|
//#endregion
|
|
@@ -226,10 +248,36 @@ function namespacedId(id) {
|
|
|
226
248
|
* @returns A string representing the short and long option pair.
|
|
227
249
|
*/
|
|
228
250
|
function makeShortLongOptionPair(schema, name, toKebab) {
|
|
229
|
-
let key = `--${
|
|
251
|
+
let key = `--${resolveDisplayName(name, schema, toKebab)}`;
|
|
230
252
|
if (schema.short) key = `-${schema.short}, ${key}`;
|
|
231
253
|
return key;
|
|
232
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Resolve the name that an argument is parsed and rendered under.
|
|
257
|
+
*
|
|
258
|
+
* @param name - The key of the argument.
|
|
259
|
+
* @param schema - The {@linkcode ArgSchema | argument schema}.
|
|
260
|
+
* @param toKebab - Whether to convert the name to kebab-case.
|
|
261
|
+
* @returns The name of the argument, kebab-cased when `toKebab` asks for it.
|
|
262
|
+
*/
|
|
263
|
+
function resolveDisplayName(name, schema, toKebab) {
|
|
264
|
+
return toKebab || schema.toKebab ? kebabnize(name) : name;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Collect the names that the arguments are parsed and rendered under.
|
|
268
|
+
*
|
|
269
|
+
* A positional argument is left out: it is never spelled as an option, so it cannot take the name of
|
|
270
|
+
* one. A `hidden` argument is counted, because it still owns the name that it parses.
|
|
271
|
+
*
|
|
272
|
+
* @param args - The {@linkcode Args | arguments} of a command, global options included.
|
|
273
|
+
* @param toKebab - Whether the command converts its names to kebab-case.
|
|
274
|
+
* @returns The set of names.
|
|
275
|
+
*/
|
|
276
|
+
function resolveOptionNames(args, toKebab) {
|
|
277
|
+
const names = /* @__PURE__ */ new Set();
|
|
278
|
+
for (const [name, schema] of Object.entries(args)) if (schema.type !== "positional") names.add(resolveDisplayName(name, schema, toKebab));
|
|
279
|
+
return names;
|
|
280
|
+
}
|
|
233
281
|
//#endregion
|
|
234
282
|
//#region ../shared/src/localization.ts
|
|
235
283
|
/**
|
|
@@ -257,15 +305,21 @@ function localizable(ctx, cmd, translate) {
|
|
|
257
305
|
if (key.startsWith(ERROR_PREFIX_AND_KEY_SEPARATOR)) return resolveAndFormatResource(key, key, values);
|
|
258
306
|
const namaspacedArgKey = resolveKey(ARG_PREFIX_AND_KEY_SEPARATOR, ctx.name);
|
|
259
307
|
if (key.startsWith(namaspacedArgKey)) {
|
|
260
|
-
|
|
261
|
-
|
|
308
|
+
const argKey = key.slice(namaspacedArgKey.length);
|
|
309
|
+
/**
|
|
310
|
+
* NOTE(kazupon): an argument describes itself, whatever it is called. `no-` is stripped only
|
|
311
|
+
* to reach the option that a negated form negates, and only when there is no argument under
|
|
312
|
+
* the name as written: `no-emoji` may be an argument of its own, and it then has nothing to do
|
|
313
|
+
* with `emoji`.
|
|
314
|
+
*/
|
|
315
|
+
const ownSchema = ctx.args[argKey];
|
|
316
|
+
if (ownSchema) return ownSchema.description || "";
|
|
262
317
|
if (argKey.startsWith("no-")) {
|
|
263
|
-
|
|
264
|
-
|
|
318
|
+
const negatedKey = argKey.slice(3);
|
|
319
|
+
const schema = ctx.args[negatedKey];
|
|
320
|
+
if (schema && schema.type === "boolean" && schema.negatable) return `${en_US_default["NEGATABLE"]} ${makeShortLongOptionPair(schema, negatedKey, ctx.toKebab)}`;
|
|
265
321
|
}
|
|
266
|
-
|
|
267
|
-
if (!schema) return argKey;
|
|
268
|
-
return negatable && schema.type === "boolean" && schema.negatable ? `${en_US_default["NEGATABLE"]} ${makeShortLongOptionPair(schema, argKey, ctx.toKebab)}` : schema.description || "";
|
|
322
|
+
return argKey;
|
|
269
323
|
}
|
|
270
324
|
if (key === resolveKey("description", ctx.name)) return "";
|
|
271
325
|
else if (key === resolveKey("examples", ctx.name)) return await resolveExamples$1(ctx, cmd.examples);
|
|
@@ -321,6 +375,63 @@ function renderHeader(ctx) {
|
|
|
321
375
|
*/
|
|
322
376
|
const pluginId = namespacedId("renderer");
|
|
323
377
|
//#endregion
|
|
378
|
+
//#region src/width.ts
|
|
379
|
+
/**
|
|
380
|
+
* @author kazuya kawaguchi (a.k.a. kazupon)
|
|
381
|
+
* @license MIT
|
|
382
|
+
*/
|
|
383
|
+
/**
|
|
384
|
+
* NOTE(kazupon): the columns of the usage are aligned by padding, and padding by `String#length`
|
|
385
|
+
* counts UTF-16 code units, which is not what a terminal draws: a full-width character takes two
|
|
386
|
+
* columns, a combining mark takes none, and a joined emoji takes two however many code points it is
|
|
387
|
+
* written with. The text is walked by grapheme, so that a mark or a joiner is part of the character
|
|
388
|
+
* it belongs to, and each grapheme is measured by the code point it starts with.
|
|
389
|
+
*
|
|
390
|
+
* JavaScript has no `\p{East_Asian_Width=Wide}` escape. Wide columns are those the engine already
|
|
391
|
+
* classifies as Han / Hiragana / Katakana / Hangul or emoji-presentation, plus the fullwidth slice
|
|
392
|
+
* of Halfwidth and Fullwidth Forms. That covers CLI names; the rest of the East Asian Width table
|
|
393
|
+
* is symbols that do not appear there. Unicode updates then come with the runtime.
|
|
394
|
+
*/
|
|
395
|
+
const WIDE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Emoji_Presentation}]/u;
|
|
396
|
+
const ZERO_WIDTH = /[\p{Default_Ignorable_Code_Point}\p{Mn}]/u;
|
|
397
|
+
function graphemeWidth(codePoint) {
|
|
398
|
+
const ch = String.fromCodePoint(codePoint);
|
|
399
|
+
if (ZERO_WIDTH.test(ch)) return 0;
|
|
400
|
+
/**
|
|
401
|
+
* Halfwidth and Fullwidth Forms: only the fullwidth slices are two columns. The rest of the
|
|
402
|
+
* block is halfwidth katakana / hangul, which `\p{Script=Katakana}` and `\p{Script=Hangul}`
|
|
403
|
+
* would otherwise count as wide.
|
|
404
|
+
*/
|
|
405
|
+
if (codePoint >= 65280 && codePoint <= 65519) {
|
|
406
|
+
if (codePoint >= 65281 && codePoint <= 65376 || codePoint >= 65504 && codePoint <= 65510) return 2;
|
|
407
|
+
return 1;
|
|
408
|
+
}
|
|
409
|
+
return WIDE.test(ch) ? 2 : 1;
|
|
410
|
+
}
|
|
411
|
+
const segmenter = new Intl.Segmenter();
|
|
412
|
+
/**
|
|
413
|
+
* Measure the columns a terminal draws a string in.
|
|
414
|
+
*
|
|
415
|
+
* @param value - A string to measure
|
|
416
|
+
* @returns The number of columns
|
|
417
|
+
*/
|
|
418
|
+
function displayWidth(value) {
|
|
419
|
+
let width = 0;
|
|
420
|
+
for (const { segment } of segmenter.segment(value)) width += graphemeWidth(segment.codePointAt(0));
|
|
421
|
+
return width;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Pad a string with spaces until it is drawn in the given number of columns.
|
|
425
|
+
*
|
|
426
|
+
* @param value - A string to pad
|
|
427
|
+
* @param width - The number of columns to pad it to
|
|
428
|
+
* @returns The padded string
|
|
429
|
+
*/
|
|
430
|
+
function padEndToWidth(value, width) {
|
|
431
|
+
const padding = width - displayWidth(value);
|
|
432
|
+
return padding > 0 ? value + " ".repeat(padding) : value;
|
|
433
|
+
}
|
|
434
|
+
//#endregion
|
|
324
435
|
//#region src/usage.ts
|
|
325
436
|
/**
|
|
326
437
|
* @author kazuya kawaguchi (a.k.a. kazupon)
|
|
@@ -428,18 +539,21 @@ async function makeUsageSymbols(ctx) {
|
|
|
428
539
|
async function renderCommandsSection(ctx) {
|
|
429
540
|
const messages = [`${await ctx.extensions[pluginId].text(resolveBuiltInKey("COMMANDS"))}:`];
|
|
430
541
|
const loadedCommands = await ctx.extensions?.[pluginId].loadCommands() || [];
|
|
431
|
-
const
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
const
|
|
435
|
-
const
|
|
436
|
-
const commandStr = await makeCommandSymbol(ctx, cmd);
|
|
437
|
-
const symbolLength = desc.length > 0 ? commandMaxLength + optionSymbol.length + positionalSymbol.length : 0;
|
|
438
|
-
const command = `${commandStr.padEnd(symbolLength + ctx.env.middleMargin)}${desc}`;
|
|
542
|
+
const commandSymbols = await Promise.all(loadedCommands.map(async (cmd) => await makeCommandSymbol(ctx, cmd)));
|
|
543
|
+
const symbolMaxLength = Math.max(...commandSymbols.map((symbol) => displayWidth(symbol)));
|
|
544
|
+
const commandsStr = (await Promise.all(loadedCommands.map(async (cmd) => await ctx.extensions[pluginId].localizeCommandDescription(cmd)))).map((desc, index) => {
|
|
545
|
+
const commandStr = commandSymbols[index];
|
|
546
|
+
const command = desc ? `${padEndToWidth(commandStr, symbolMaxLength + ctx.env.middleMargin)}${desc}` : commandStr;
|
|
439
547
|
return `${command.padStart(ctx.env.leftMargin + command.length)}`;
|
|
440
|
-
})
|
|
548
|
+
});
|
|
441
549
|
messages.push(...commandsStr, "", `${await ctx.extensions[pluginId].text(resolveBuiltInKey("FORMORE"))}:`);
|
|
442
|
-
|
|
550
|
+
/**
|
|
551
|
+
* NOTE(kazupon): the CLI is named the same way the usage line and the entry row of the section
|
|
552
|
+
* above name it, because `CliOptions.name` is optional and the block would otherwise invite the
|
|
553
|
+
* reader to run `undefined --help`.
|
|
554
|
+
*/
|
|
555
|
+
const entry = await resolveEntry(ctx);
|
|
556
|
+
const basePath = ctx.commandPath && ctx.commandPath.length > 0 ? `${entry} ${ctx.commandPath.join(" ")}` : entry;
|
|
443
557
|
messages.push(...loadedCommands.map((cmd) => {
|
|
444
558
|
let commandStr = cmd.entry ? "" : cmd.name || "";
|
|
445
559
|
if (commandStr) commandStr += " ";
|
|
@@ -449,9 +563,15 @@ async function renderCommandsSection(ctx) {
|
|
|
449
563
|
return messages;
|
|
450
564
|
}
|
|
451
565
|
async function makeCommandSymbol(ctx, cmd) {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
566
|
+
/**
|
|
567
|
+
* NOTE(kazupon): the row describes `cmd`, so its symbols come from what `cmd` declares.
|
|
568
|
+
* A lazy command is described by the definition that is given to `lazy`, since the command
|
|
569
|
+
* list does not run the loader.
|
|
570
|
+
*/
|
|
571
|
+
const args = cmd.args || {};
|
|
572
|
+
const optionSymbol = await generateOptionsSymbols(ctx, args);
|
|
573
|
+
const positionalSymbol = generatePositionalSymbols(args);
|
|
574
|
+
let commandStr = cmd.entry ? cmd.name === void 0 || cmd.name === ANONYMOUS_COMMAND_NAME ? `[${await resolveEntry(ctx)}]` : `[${cmd.name}]` : cmd.name || "";
|
|
455
575
|
if (optionSymbol) {
|
|
456
576
|
if (commandStr) commandStr += " ";
|
|
457
577
|
commandStr += `${optionSymbol}`;
|
|
@@ -537,7 +657,11 @@ function hasPositionalArgs(args) {
|
|
|
537
657
|
*/
|
|
538
658
|
function hasAllDefaultOptions(args) {
|
|
539
659
|
const visibleOptionalArgs = getVisibleOptionalArgs(args);
|
|
540
|
-
|
|
660
|
+
/**
|
|
661
|
+
* NOTE(kazupon): an argument has a default when one is declared, whatever its value. `0`, `''` and
|
|
662
|
+
* `false` are defaults like any other, and an option that has one is not required (#750).
|
|
663
|
+
*/
|
|
664
|
+
return visibleOptionalArgs.length > 0 && visibleOptionalArgs.every(([_, arg]) => arg.default !== void 0);
|
|
541
665
|
}
|
|
542
666
|
/**
|
|
543
667
|
* Generate options symbols for usage
|
|
@@ -556,23 +680,32 @@ async function generateOptionsSymbols(ctx, args) {
|
|
|
556
680
|
* @returns Options pairs for usage
|
|
557
681
|
*/
|
|
558
682
|
function getOptionalArgsPairs(ctx) {
|
|
683
|
+
/**
|
|
684
|
+
* NOTE(kazupon): an argument owns the name that it renders under, so the negated form of another
|
|
685
|
+
* option must not be listed under it (#732). The names are compared as they are rendered, because
|
|
686
|
+
* `toKebab` can bring two different keys under one name (#743). A `hidden` argument counts: it is
|
|
687
|
+
* not listed, and neither is a negated form that would take its name.
|
|
688
|
+
*/
|
|
689
|
+
const optionNames = resolveOptionNames(ctx.args, ctx.toKebab);
|
|
559
690
|
return getVisibleOptionalArgs(ctx.args).reduce((acc, [name, schema]) => {
|
|
691
|
+
const displayName = resolveDisplayName(name, schema, ctx.toKebab);
|
|
560
692
|
let key = makeShortLongOptionPair(schema, name, ctx.toKebab);
|
|
561
|
-
if (schema.type !== "boolean") {
|
|
562
|
-
const displayName = ctx.toKebab || schema.toKebab ? kebabnize(name) : name;
|
|
563
|
-
key = schema.default ? `${key} [${displayName}]` : `${key} <${displayName}>`;
|
|
564
|
-
}
|
|
693
|
+
if (schema.type !== "boolean") key = schema.default === void 0 ? `${key} <${displayName}>` : `${key} [${displayName}]`;
|
|
565
694
|
acc[name] = key;
|
|
566
695
|
if (schema.type === "boolean" && schema.negatable && !COMMON_ARGS_KEYS.includes(name)) {
|
|
567
|
-
const
|
|
568
|
-
acc[`no-${name}`] =
|
|
696
|
+
const negatedName = `no-${displayName}`;
|
|
697
|
+
if (!optionNames.has(negatedName)) acc[`no-${name}`] = `--${negatedName}`;
|
|
569
698
|
}
|
|
570
699
|
return acc;
|
|
571
700
|
}, Object.create(null));
|
|
572
701
|
}
|
|
573
|
-
const resolveNegatableKey = (key) => key.
|
|
702
|
+
const resolveNegatableKey = (key) => key.slice(3);
|
|
574
703
|
function resolveNegatableType(key, ctx) {
|
|
575
|
-
|
|
704
|
+
/**
|
|
705
|
+
* NOTE(kazupon): the key of an argument wins over the option that a negated form negates, and a
|
|
706
|
+
* negated form is a boolean flag whether or not the option it negates can be reached from here.
|
|
707
|
+
*/
|
|
708
|
+
return (ctx.args[key] ?? ctx.args[resolveNegatableKey(key)])?.type ?? "boolean";
|
|
576
709
|
}
|
|
577
710
|
async function generateDefaultDisplayValue(ctx, schema) {
|
|
578
711
|
return `${await ctx.extensions[pluginId].text(resolveBuiltInKey("DEFAULT"))}: ${schema.default}`;
|
|
@@ -598,21 +731,26 @@ async function resolveDisplayValue(ctx, key) {
|
|
|
598
731
|
async function generateOptionalArgsUsage(ctx, optionsPairs) {
|
|
599
732
|
const optionsPairsEntries = Object.entries(optionsPairs);
|
|
600
733
|
if (optionsPairsEntries.length === 0) return "";
|
|
601
|
-
const optionsMaxLength = Math.max(...optionsPairsEntries.map(([_, value]) => value
|
|
602
|
-
const optionSchemaMaxLength = ctx.env.usageOptionType ? Math.max(...optionsPairsEntries.map(([key]) => resolveNegatableType(key, ctx)
|
|
734
|
+
const optionsMaxLength = Math.max(...optionsPairsEntries.map(([_, value]) => displayWidth(value)));
|
|
735
|
+
const optionSchemaMaxLength = ctx.env.usageOptionType ? Math.max(...optionsPairsEntries.map(([key]) => displayWidth(resolveNegatableType(key, ctx)))) : 0;
|
|
603
736
|
return (await Promise.all(optionsPairsEntries.map(async ([key, value]) => {
|
|
604
737
|
let rawDesc = await ctx.extensions[pluginId].text(resolveArgKey(key, ctx.name));
|
|
605
|
-
if (!rawDesc && key.startsWith("no-")) {
|
|
738
|
+
if (!rawDesc && !ctx.args[key] && key.startsWith("no-")) {
|
|
739
|
+
/**
|
|
740
|
+
* NOTE(kazupon): only a key that no argument answers to may be read as the negated form of
|
|
741
|
+
* another option, and only when that option is there to be negated.
|
|
742
|
+
*/
|
|
606
743
|
const name = resolveNegatableKey(key);
|
|
607
744
|
const schema = ctx.args[name];
|
|
608
|
-
|
|
609
|
-
|
|
745
|
+
if (schema) {
|
|
746
|
+
const optionKey = makeShortLongOptionPair(schema, name, ctx.toKebab);
|
|
747
|
+
rawDesc = `${await ctx.extensions[pluginId].text(resolveBuiltInKey("NEGATABLE"))} ${optionKey}`;
|
|
748
|
+
}
|
|
610
749
|
}
|
|
611
750
|
const optionsSchema = ctx.env.usageOptionType ? `[${resolveNegatableType(key, ctx)}] ` : "";
|
|
612
751
|
const valueDesc = key.startsWith("no-") ? "" : await resolveDisplayValue(ctx, key);
|
|
613
|
-
const desc = `${optionsSchema ? optionsSchema
|
|
614
|
-
const
|
|
615
|
-
const option = `${value.padEnd((descLength > 0 ? optionsMaxLength : 0) + ctx.env.middleMargin)}${desc}${valueDesc ? ` ${valueDesc}` : ""}`;
|
|
752
|
+
const desc = `${optionsSchema ? padEndToWidth(optionsSchema, optionSchemaMaxLength + 3) : ""}${rawDesc}`;
|
|
753
|
+
const option = `${padEndToWidth(value, (desc.length + valueDesc.length > 0 ? optionsMaxLength : 0) + ctx.env.middleMargin)}${desc}${valueDesc ? ` ${valueDesc}` : ""}`;
|
|
616
754
|
return `${option.padStart(ctx.env.leftMargin + option.length)}`;
|
|
617
755
|
}))).join("\n");
|
|
618
756
|
}
|
|
@@ -622,10 +760,10 @@ function getPositionalArgs(args) {
|
|
|
622
760
|
async function generatePositionalArgsUsage(ctx) {
|
|
623
761
|
const positionals = getPositionalArgs(ctx.args);
|
|
624
762
|
if (positionals.length === 0) return "";
|
|
625
|
-
const argsMaxLength = Math.max(...positionals.map(([name]) => name
|
|
763
|
+
const argsMaxLength = Math.max(...positionals.map(([name]) => displayWidth(name)));
|
|
626
764
|
return (await Promise.all(positionals.map(async ([name]) => {
|
|
627
765
|
const desc = await ctx.extensions[pluginId].text(resolveArgKey(name, ctx.name)) || ctx.args[name].description || "";
|
|
628
|
-
const arg = `${name
|
|
766
|
+
const arg = `${padEndToWidth(name, argsMaxLength + ctx.env.middleMargin)} ${desc}`;
|
|
629
767
|
return `${arg.padStart(ctx.env.leftMargin + arg.length)}`;
|
|
630
768
|
}))).join("\n");
|
|
631
769
|
}
|
|
@@ -669,6 +807,10 @@ async function renderValidationError(ctx, error) {
|
|
|
669
807
|
const message = await localize(ctx, error.code, error.values);
|
|
670
808
|
if (message && message !== error.code) return message;
|
|
671
809
|
}
|
|
810
|
+
if (isCommandResolutionError(error) && error.code) {
|
|
811
|
+
const message = await localize(ctx, error.code, error.values);
|
|
812
|
+
if (message && message !== error.code) return message;
|
|
813
|
+
}
|
|
672
814
|
if (isArgsValidationError(error) && error.code) {
|
|
673
815
|
const values = await resolveValidationValues(ctx, error);
|
|
674
816
|
const message = await localize(ctx, error.code, values);
|
|
@@ -748,7 +890,10 @@ function renderer() {
|
|
|
748
890
|
async function loadCommands() {
|
|
749
891
|
if (cachedCommands) return cachedCommands;
|
|
750
892
|
const subCommands = [...ctx.env.subCommands || []];
|
|
751
|
-
|
|
893
|
+
const allCommands = await Promise.all(subCommands.map(async ([name, cmd]) => await resolveLazyCommand(cmd, name)));
|
|
894
|
+
const entryCommand = ctx.env.entryCommand;
|
|
895
|
+
if (ctx.callMode === "entry" && subCommands.length > 0 && entryCommand && !subCommands.some(([, command]) => command === entryCommand)) allCommands.push(await resolveLazyCommand(entryCommand, ANONYMOUS_COMMAND_NAME));
|
|
896
|
+
cachedCommands = allCommands.filter((cmd) => !cmd.internal).filter(Boolean);
|
|
752
897
|
cachedCommands.sort((a, b) => {
|
|
753
898
|
if (a.entry && !b.entry) return -1;
|
|
754
899
|
if (!a.entry && b.entry) return 1;
|
|
@@ -759,9 +904,33 @@ function renderer() {
|
|
|
759
904
|
});
|
|
760
905
|
return cachedCommands;
|
|
761
906
|
}
|
|
907
|
+
/**
|
|
908
|
+
* Resolve the description of a command in the command list.
|
|
909
|
+
*
|
|
910
|
+
* NOTE(kazupon): the description is localized the way the command's own help localizes it,
|
|
911
|
+
* which needs that command's resource. `text()` is bound to the command that is running, so it
|
|
912
|
+
* cannot answer for another one. The i18n plugin loads a resource into the adapter under the
|
|
913
|
+
* name of the command it is given, and `translate()` reads it back from there (#748).
|
|
914
|
+
*
|
|
915
|
+
* @param target - A command from `loadCommands`
|
|
916
|
+
* @returns The description to show for the command
|
|
917
|
+
*/
|
|
918
|
+
async function localizeCommandDescription(target) {
|
|
919
|
+
const fallback = target.description || "";
|
|
920
|
+
if (!i18n || !target.name) return fallback;
|
|
921
|
+
const commandCtx = await createCommandContext({
|
|
922
|
+
args: target.args,
|
|
923
|
+
command: target,
|
|
924
|
+
callMode: target.entry ? "entry" : "subCommand",
|
|
925
|
+
extensions: {}
|
|
926
|
+
});
|
|
927
|
+
await i18n.loadResource(i18n.locale, commandCtx, target);
|
|
928
|
+
return await i18n.translate(resolveKey("description", target.name)) || fallback;
|
|
929
|
+
}
|
|
762
930
|
return {
|
|
763
931
|
text: localizable(ctx, cmd, i18n?.translate),
|
|
764
|
-
loadCommands
|
|
932
|
+
loadCommands,
|
|
933
|
+
localizeCommandDescription
|
|
765
934
|
};
|
|
766
935
|
},
|
|
767
936
|
setup: (ctx) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gunshi/plugin-renderer",
|
|
3
3
|
"description": "usage renderer plugin for gunshi",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "1.0.0-beta.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "kazuya kawaguchi",
|
|
7
7
|
"email": "kawakazu80@gmail.com"
|
|
@@ -53,10 +53,10 @@
|
|
|
53
53
|
}
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@gunshi/plugin": "0.
|
|
56
|
+
"@gunshi/plugin": "1.0.0-beta.0"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@gunshi/plugin-i18n": "0.
|
|
59
|
+
"@gunshi/plugin-i18n": "1.0.0-beta.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"deno": "^2.7.14",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"jsr-exports-lint": "^0.4.2",
|
|
65
65
|
"publint": "^0.3.20",
|
|
66
66
|
"tsdown": "0.21.0",
|
|
67
|
-
"@gunshi/shared": "0.
|
|
67
|
+
"@gunshi/shared": "1.0.0-beta.0"
|
|
68
68
|
},
|
|
69
69
|
"scripts": {
|
|
70
70
|
"build": "tsdown",
|