@gunshi/plugin-renderer 0.37.3 → 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 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 subcommands for rendering command lists. Results are cached after the first call for performance.
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
 
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.29.0/node_modules/args-tokens/lib/utils.js
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 && !!cmd.commandName;
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 = `--${toKebab || schema.toKebab ? kebabnize(name) : name}`;
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
- let argKey = key.slice(namaspacedArgKey.length);
261
- let negatable = false;
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
- argKey = argKey.slice(3);
264
- negatable = true;
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
- const schema = ctx.args[argKey];
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 commandMaxLength = Math.max(...loadedCommands.map((cmd) => (cmd.name || "").length));
432
- const commandsStr = await Promise.all(loadedCommands.map(async (cmd) => {
433
- const desc = cmd.description || "";
434
- const optionSymbol = await generateOptionsSymbols(ctx, ctx.args);
435
- const positionalSymbol = generatePositionalSymbols(ctx.args);
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
- const basePath = ctx.commandPath && ctx.commandPath.length > 0 ? `${ctx.env.name} ${ctx.commandPath.join(" ")}` : ctx.env.name;
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
- const optionSymbol = await generateOptionsSymbols(ctx, ctx.args);
453
- const positionalSymbol = generatePositionalSymbols(ctx.args);
454
- let commandStr = cmd.entry ? cmd.name === void 0 || cmd.name === ANONYMOUS_COMMAND_NAME ? "" : `[${cmd.name}]` : cmd.name || "";
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
- return visibleOptionalArgs.length > 0 && visibleOptionalArgs.every(([_, arg]) => arg.default);
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 displayName = ctx.toKebab || schema.toKebab ? kebabnize(name) : name;
568
- acc[`no-${name}`] = `--no-${displayName}`;
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.split("no-")[1];
702
+ const resolveNegatableKey = (key) => key.slice(3);
574
703
  function resolveNegatableType(key, ctx) {
575
- return ctx.args[key.startsWith("no-") ? resolveNegatableKey(key) : key].type;
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.length));
602
- const optionSchemaMaxLength = ctx.env.usageOptionType ? Math.max(...optionsPairsEntries.map(([key]) => resolveNegatableType(key, ctx).length)) : 0;
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
- const optionKey = makeShortLongOptionPair(schema, name, ctx.toKebab);
609
- rawDesc = `${await ctx.extensions[pluginId].text(resolveBuiltInKey("NEGATABLE"))} ${optionKey}`;
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.padEnd(optionSchemaMaxLength + 3) : ""}${rawDesc}`;
614
- const descLength = desc.length + valueDesc.length;
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.length));
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.padEnd(argsMaxLength + ctx.env.middleMargin)} ${desc}`;
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
- cachedCommands = (await Promise.all(subCommands.map(async ([name, cmd]) => await resolveLazyCommand(cmd, name)))).filter((cmd) => !cmd.internal).filter(Boolean);
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.37.3",
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.37.3"
56
+ "@gunshi/plugin": "1.0.0-beta.0"
57
57
  },
58
58
  "peerDependencies": {
59
- "@gunshi/plugin-i18n": "0.37.3"
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.37.3"
67
+ "@gunshi/shared": "1.0.0-beta.0"
68
68
  },
69
69
  "scripts": {
70
70
  "build": "tsdown",