@zhin.js/command 1.1.10 → 1.1.11

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.
@@ -1,3 +1,4 @@
1
+ import { commandMessages } from './messages.js';
1
2
  export const MENU_PAGE_SIZE = 30;
2
3
  const escape = (value) => value.replace(/[&<>"']/gu, char => ({
3
4
  '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
@@ -46,6 +47,7 @@ function icon(name, plugin = false) {
46
47
  }
47
48
  /** Presentation only: the unified outbound pipeline owns image rendering and fallback. */
48
49
  export function renderCommandMenu(title, commands, children, hint, text, options = {}) {
50
+ const locale = options.locale ?? 'zh-CN';
49
51
  const appearance = options.appearance ?? {};
50
52
  const background = appearance.backgroundImage ?? `data:image/svg+xml,${encodeURIComponent(landscape)}`;
51
53
  // CSS string escaping additionally prevents an artwork URL from closing <style>.
@@ -60,7 +62,7 @@ export function renderCommandMenu(title, commands, children, hint, text, options
60
62
  type: 'html',
61
63
  data: {
62
64
  width: 1080, fileName: 'command-menu.png', text,
63
- html: `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><style>
65
+ html: `<!doctype html><html lang="${locale}"><head><meta charset="utf-8"><style>
64
66
  *{box-sizing:border-box}body{margin:0;background:#253b46;color:#f4f0e6;font-family:"PingFang SC","Microsoft YaHei",sans-serif;font-size:18px;line-height:1.5;--accent:${appearance.accentColor ?? '#e7c993'}}
65
67
  main{padding:42px 28px 24px;background-image:linear-gradient(180deg,#0c202a0a,#0c202a55),url(${cssUrl});background-size:cover;background-position:center top}
66
68
  header{min-height:210px;padding:10px 22px 30px;position:relative}.eyebrow{font-size:12px;letter-spacing:5px;color:#f4e4c4;margin:0 0 17px}
@@ -71,11 +73,11 @@ h2{margin:0;font-size:20px;font-weight:700;letter-spacing:1px;color:var(--accent
71
73
  .grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));background:#20363fdb;border-radius:0 0 13px 13px;overflow:hidden}
72
74
  .entry{display:flex;align-items:center;gap:12px;padding:19px 17px;min-width:0;min-height:106px;border-top:1px solid #e7e6d614;border-right:1px solid #e7e6d614;background:linear-gradient(120deg,#ffffff08,#ffffff00)}.entry:nth-child(3n){border-right:0}
73
75
  .icon{width:35px;height:35px;flex:0 0 35px;color:var(--accent);filter:drop-shadow(0 1px 6px #d9ba7c22)}.icon svg{display:block;width:100%;height:100%}.copy{min-width:0}.name{font-size:18px;font-weight:700;color:var(--accent)}.description{margin:4px 0 0;color:#e1e8e5;font-size:15px;line-height:1.45}
74
- .name,.description,h1{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere;white-space:pre-wrap}
76
+ .name{overflow-wrap:anywhere;white-space:pre-wrap}.description,h1{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere;white-space:pre-wrap}
75
77
  footer{margin:6px 5px 0;padding:15px 18px;display:flex;align-items:center;justify-content:space-between;gap:22px;background:#142a34da;border:1px solid #f0e2c71c;border-radius:13px}.hint{font-size:14px;color:#cfdbd7;white-space:pre-wrap;overflow-wrap:anywhere}.page{font-size:20px;color:var(--accent);white-space:nowrap;font-family:Georgia,serif}.signature{margin:16px 0 0;text-align:center;font-size:10px;letter-spacing:4px;color:#c4d0c899}.empty{margin:0 0 20px;padding:28px;background:#142a34da;border-radius:16px;color:#dbe4de}
76
- </style></head><body><main><header><div class="eyebrow">ZHIN / COMMAND ATLAS</div><h1>${escape(appearance.title || `${title} · 指令指南`)}</h1><div class="subtitle">${escape(appearance.subtitle ?? '每一个灵感,都有回应。')}</div><div class="summary"><i></i>${options.commandTotal ?? commands.length} 条指令<span>·</span>${options.pluginTotal ?? children.length} 个子插件<span>·</span>${escape(title)}</div></header>
77
- ${section('指令索引', commands, options.commandTotal ?? commands.length)}${section('插件导航', children, options.pluginTotal ?? children.length, true)}${commands.length || children.length ? '' : '<p class="empty">暂无指令和子插件</p>'}
78
- <footer><div class="hint">${escape(hint ?? '查看参数与用法:指令名 --help')}</div><div class="page">${String(page).padStart(2, '0')} / ${String(pages).padStart(2, '0')}</div></footer><div class="signature">ZHIN · MAKE EVERY MESSAGE MATTER</div></main></body></html>`,
78
+ </style></head><body><main><header><div class="eyebrow">ZHIN / COMMAND ATLAS</div><h1>${escape(appearance.title || commandMessages(locale, 'guideTitle', { title }))}</h1><div class="subtitle">${escape(appearance.subtitle ?? commandMessages(locale, 'guideSubtitle'))}</div><div class="summary"><i></i>${escape(commandMessages(locale, 'commandCount', { count: options.commandTotal ?? commands.length }))}<span>·</span>${escape(commandMessages(locale, 'pluginCount', { count: options.pluginTotal ?? children.length }))}<span>·</span>${escape(title)}</div></header>
79
+ ${section(commandMessages(locale, 'commandIndex'), commands, options.commandTotal ?? commands.length)}${section(commandMessages(locale, 'pluginIndex'), children, options.pluginTotal ?? children.length, true)}${commands.length || children.length ? '' : `<p class="empty">${commandMessages(locale, 'guideEmpty')}</p>`}
80
+ <footer><div class="hint">${escape(hint ?? commandMessages(locale, 'genericHelpHint'))}</div><div class="page">${String(page).padStart(2, '0')} / ${String(pages).padStart(2, '0')}</div></footer><div class="signature">ZHIN · MAKE EVERY MESSAGE MATTER</div></main></body></html>`,
79
81
  },
80
82
  };
81
83
  }
@@ -2,7 +2,7 @@
2
2
  * Command authoring API consumed from `zhin.js/command`.
3
3
  * @module zhin.js/command
4
4
  */
5
- import type { PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
5
+ import { type Locale, type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
6
6
  import { type AdapterClient, type RegisteredAdapterName, type CapabilityContext } from '@zhin.js/feature-kit';
7
7
  import type { UserInteraction } from '@zhin.js/interaction';
8
8
  import { type CommandExecutionPolicy } from './execution-policy.js';
@@ -41,6 +41,8 @@ export interface CommandParamSchema {
41
41
  readonly type: CommandParameterType;
42
42
  readonly default?: CommandDynamicValue;
43
43
  readonly description?: string;
44
+ /** Optional translated display text; machine keys and values remain unchanged. */
45
+ readonly descriptionI18n?: Readonly<Partial<Record<Locale, string>>>;
44
46
  readonly optional?: boolean;
45
47
  readonly rest?: boolean;
46
48
  readonly min?: number;
@@ -95,6 +97,7 @@ export interface CommandParameterDefinition {
95
97
  /** `[...name]` / `[[...name]]` 捕获所有段,运行时值为 `string[]`。 */
96
98
  readonly rest?: boolean;
97
99
  readonly description?: string;
100
+ readonly descriptionI18n?: Readonly<Partial<Record<Locale, string>>>;
98
101
  }
99
102
  /** 场景:群 / 私聊 / 频道等。 */
100
103
  export interface CommandScene {
@@ -138,6 +141,8 @@ export interface CommandConversation {
138
141
  export interface CommandMessage {
139
142
  readonly conversation: CommandConversation;
140
143
  readonly content: string;
144
+ /** Display preference resolved by the IM runtime, when this is an IM operation. */
145
+ readonly locale?: Locale;
141
146
  /** 发送者(结构化视图见 CommandContext.sender)。 */
142
147
  readonly sender?: {
143
148
  readonly id: string;
@@ -206,6 +211,16 @@ export interface CommandSession {
206
211
  * ```
207
212
  */
208
213
  export interface CommandContext<TConfig = unknown, TInput extends CommandMessage = CommandMessage, TAdapter extends string | undefined = undefined> extends CapabilityContext<TConfig>, CommandSession {
214
+ /** Language resolved once for the current operation. */
215
+ readonly locale: Locale;
216
+ /**
217
+ * Resolve a static local command path to its current chat invocation. Defaults to this
218
+ * plugin; an explicit owner may reference another indexed plugin.
219
+ * Includes the actual Endpoint prefix and configured commandNamespace. Arguments
220
+ * are appended explicitly by the caller. Returns undefined for unknown/disabled
221
+ * paths or calls without a known chat command environment.
222
+ */
223
+ formatCommand(localPath: string, owner?: PluginId): string | undefined;
209
224
  readonly args: readonly string[];
210
225
  readonly params: Readonly<Record<string, CommandParameterValue>>;
211
226
  readonly options: Readonly<Record<string, CommandParameterValue>>;
@@ -230,6 +245,7 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
230
245
  /** @internal Convention-derived parameter metadata. */
231
246
  readonly $parameter?: CommandParameterDefinition;
232
247
  readonly description?: string;
248
+ readonly descriptionI18n?: Readonly<Partial<Record<Locale, string>>>;
233
249
  /** Restrict this command to one adapter and infer `context.$client`. */
234
250
  readonly adapter?: TAdapter;
235
251
  /**
@@ -243,6 +259,8 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
243
259
  * 用户路由;alias 只替换文件路径提供的静态段。
244
260
  */
245
261
  readonly alias?: readonly string[];
262
+ /** Display an explicitly declared alias for this locale; every value must also occur in alias. */
263
+ readonly preferredAliasI18n?: Readonly<Partial<Record<Locale, string>>>;
246
264
  /**
247
265
  * 内置 permit DSL(AND)。单项内逗号为 OR。
248
266
  * 例:`adapter(icqq)`、`role(master)`、`group(123,456)`。
@@ -292,6 +310,8 @@ export declare function defineCommand<TConfig = unknown, TResult = unknown, TInp
292
310
  export declare namespace defineCommand {
293
311
  var withConfig: <TConfig = unknown, TInput extends CommandMessage = CommandMessage>() => CommandDefiner<TConfig, TInput>;
294
312
  }
313
+ /** @internal Validate display-only translations without changing their fallback text. */
314
+ export declare function validateDescriptionI18n(value: CommandDefinition['descriptionI18n']): void;
295
315
  /** @internal Convention-loader assembly helper. */
296
316
  export declare function bindCommandParameter<TConfig, TResult, TInput extends CommandMessage, TAdapter extends string | undefined>(definition: CommandDefinition<TConfig, TResult, TInput, TAdapter>, parameter: CommandParameterDefinition | undefined): Readonly<CommandDefinition<TConfig, TResult, TInput, TAdapter>>;
297
317
  /** @internal Runtime validation for convention-discovered modules. */
@@ -303,7 +323,10 @@ export declare function parseCommandDefinition(value: unknown): CommandDefinitio
303
323
  */
304
324
  export declare function resolveDynamicParams(params: Readonly<Record<string, CommandDynamicValue>>, source: unknown): Readonly<Record<string, CommandParameterValue>>;
305
325
  /** @internal Command dispatcher assembly helper. */
306
- export declare function createCommandContext(snapshot: RuntimeSnapshot, ownerId: PluginId, args: readonly string[], params?: Readonly<Record<string, CommandParameterValue>>, input?: unknown, segments?: readonly Readonly<CommandSegment>[], interaction?: UserInteraction, adapter?: string, options?: Readonly<Record<string, CommandParameterValue>>): CommandContext;
326
+ export declare function createCommandContext(snapshot: RuntimeSnapshot, ownerId: PluginId, args: readonly string[], params?: Readonly<Record<string, CommandParameterValue>>, input?: unknown, segments?: readonly Readonly<CommandSegment>[], interaction?: UserInteraction, adapter?: string, options?: Readonly<Record<string, CommandParameterValue>>, presentation?: Readonly<{
327
+ locale: Locale;
328
+ formatCommand?: (localPath: string, owner?: PluginId) => string | undefined;
329
+ }>): CommandContext;
307
330
  /**
308
331
  * 从派发来源(通常是 Runtime `Message`)解析入站快捷字段。
309
332
  * 不依赖 `@zhin.js/core`,按 {@link CommandMessage} 结构鸭式识别。
package/lib/definition.js CHANGED
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Command authoring API consumed from `zhin.js/command`.
3
+ * @module zhin.js/command
4
+ */
5
+ import { resolveLocale } from '@zhin.js/plugin-runtime';
1
6
  import { createCapabilityContext, readOperationClient, readOperationInputSignal, } from '@zhin.js/feature-kit';
2
7
  import { assertPermitSyntax } from '@zhin.js/permission';
3
8
  import { validateArgumentSchemas } from './arguments.js';
@@ -37,12 +42,23 @@ export function defineCommand(definition) {
37
42
  }
38
43
  }
39
44
  validateArgumentSchemas(definition.params, definition.options);
45
+ validateDescriptionI18n(definition.descriptionI18n);
40
46
  validateCommandAlias(definition.alias);
47
+ validatePreferredAliases(definition);
41
48
  validateCommandPermit(definition.permit);
42
49
  validateCommandShortcutShape(definition.shortcut);
43
50
  const execution = normalizeExecutionPolicy(definition.execution);
44
51
  return Object.freeze({ $feature: commandBrand, ...definition, ...(execution ? { execution } : {}) });
45
52
  }
53
+ /** @internal Validate display-only translations without changing their fallback text. */
54
+ export function validateDescriptionI18n(value) {
55
+ if (value === undefined)
56
+ return;
57
+ if (!value || typeof value !== 'object' || Array.isArray(value)
58
+ || Object.entries(value).some(([locale, text]) => !['zh-CN', 'en'].includes(locale) || typeof text !== 'string')) {
59
+ throw new TypeError('Command descriptionI18n must map zh-CN/en to strings');
60
+ }
61
+ }
46
62
  /**
47
63
  * 先固定插件 config 类型,再定义命令;params / options / 返回值仍从定义推断。
48
64
  *
@@ -66,6 +82,19 @@ function validateAdapterName(adapter) {
66
82
  throw new TypeError('Command adapter must be a non-empty string');
67
83
  }
68
84
  }
85
+ function validatePreferredAliases(definition) {
86
+ const preferred = definition.preferredAliasI18n;
87
+ if (preferred === undefined)
88
+ return;
89
+ validateDescriptionI18n(preferred);
90
+ const normalize = (value) => value.trim().split(/\s+/u).filter(Boolean).join(' ');
91
+ const aliases = new Set(definition.alias?.map(normalize));
92
+ for (const [locale, alias] of Object.entries(preferred)) {
93
+ if (!aliases.has(normalize(alias))) {
94
+ throw new TypeError(`Command preferredAliasI18n.${locale} must reference a declared alias`);
95
+ }
96
+ }
97
+ }
69
98
  function validateCommandAlias(alias) {
70
99
  if (alias === undefined)
71
100
  return;
@@ -127,6 +156,8 @@ export function parseCommandDefinition(value) {
127
156
  }
128
157
  validateAdapterName(definition.adapter);
129
158
  normalizeExecutionPolicy(definition.execution);
159
+ validateCommandAlias(definition.alias);
160
+ validatePreferredAliases(definition);
130
161
  return definition;
131
162
  }
132
163
  /**
@@ -143,7 +174,7 @@ export function resolveDynamicParams(params, source) {
143
174
  return Object.freeze(resolved);
144
175
  }
145
176
  /** @internal Command dispatcher assembly helper. */
146
- export function createCommandContext(snapshot, ownerId, args, params = Object.freeze({}), input = undefined, segments = Object.freeze([]), interaction, adapter, options = Object.freeze({})) {
177
+ export function createCommandContext(snapshot, ownerId, args, params = Object.freeze({}), input = undefined, segments = Object.freeze([]), interaction, adapter, options = Object.freeze({}), presentation) {
147
178
  const context = createCapabilityContext(snapshot, ownerId, undefined, readOperationInputSignal(input));
148
179
  const session = resolveCommandSession(input);
149
180
  const parameterValues = { __proto__: null, ...params };
@@ -151,6 +182,8 @@ export function createCommandContext(snapshot, ownerId, args, params = Object.fr
151
182
  const result = {
152
183
  ...context,
153
184
  ...session,
185
+ locale: presentation?.locale ?? resolveLocale(snapshot.config.get(snapshot.root)?.locale),
186
+ formatCommand: presentation?.formatCommand ?? (() => undefined),
154
187
  args: Object.freeze([...args]),
155
188
  params: Object.freeze(parameterValues),
156
189
  options: Object.freeze(optionValues),
package/lib/index.d.ts CHANGED
@@ -10,4 +10,4 @@ export * from './permission-explanation.js';
10
10
  export { CONVERSATION_MANAGER_PERMIT, canManageConversation, assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, type ParsedPermit, type PermitKind, } from './permit.js';
11
11
  export { bindCommandSource, CommandPathSyntaxError, commandFeatureId, type BoundCommandSource, default as commandFeature, } from './provider.js';
12
12
  export { default } from './provider.js';
13
- export type { CommandValidationIssue } from './arguments.js';
13
+ export type { CommandValidationIssue, CommandValidationIssueCode } from './arguments.js';
@@ -0,0 +1,8 @@
1
+ import { type Locale } from '@zhin.js/plugin-runtime';
2
+ /** Translates only display metadata explicitly supplied by its author. */
3
+ export declare function commandDescription(value: Readonly<{
4
+ description?: string;
5
+ descriptionI18n?: Readonly<Partial<Record<Locale, string>>>;
6
+ }>, locale: Locale): string | undefined;
7
+ /** Static dictionaries; the caller supplies the operation's language. */
8
+ export declare const commandMessages: (locale: Locale | undefined, key: "choices" | "defaultValue" | "menuKeyword" | "invalidPage" | "missingPlugin" | "pageOutOfRange" | "menuTitle" | "menuSummary" | "childPlugins" | "emptyMenu" | "pluginHint" | "previousPage" | "nextPage" | "guideTitle" | "guideSubtitle" | "commandCount" | "pluginCount" | "commandIndex" | "pluginIndex" | "guideEmpty" | "helpHint" | "genericHelpHint" | "expectedType" | "expectedArray" | "minimum" | "maximum" | "invalidFormat" | "invalidValue" | "unknownOption" | "duplicateOption" | "defaultUndefined" | "parameterUndefined" | "missingOption" | "unexpectedArguments" | "invalidParameter" | "dynamicValue" | "noAdapter" | "missingEndpoint" | "adapterComparison" | "conditionMismatch" | "missingPermissionHost" | "missingEvidence" | "noPermit" | "uncheckedConditions" | "conditionsMatch" | "unconstrained" | "simulatedIdentity" | "unevaluatedCode" | "defaultedRole", values?: import("@zhin.js/plugin-runtime").MessageValues) => string;
@@ -0,0 +1,57 @@
1
+ import { createMessages } from '@zhin.js/plugin-runtime';
2
+ /** Translates only display metadata explicitly supplied by its author. */
3
+ export function commandDescription(value, locale) {
4
+ return value.descriptionI18n?.[locale] ?? value.description;
5
+ }
6
+ /** Static dictionaries; the caller supplies the operation's language. */
7
+ export const commandMessages = createMessages({
8
+ menuKeyword: { 'zh-CN': '菜单', en: 'menu' },
9
+ invalidPage: { 'zh-CN': '页码必须是正整数。用法:{command} [插件名] --page <页码>', en: 'The page must be a positive integer. Usage: {command} [plugin] --page <page>' },
10
+ missingPlugin: { 'zh-CN': '未找到插件: {plugin}', en: 'Plugin not found: {plugin}' },
11
+ pageOutOfRange: { 'zh-CN': '页码超出范围:共 {pages} 页。请使用「{command} --page <页码>」。', en: 'Page out of range: {pages} pages available. Use {command} --page <page>.' },
12
+ menuTitle: { 'zh-CN': '=== {plugin} 指令菜单 ===', en: '=== {plugin} command menu ===' },
13
+ menuSummary: { 'zh-CN': '第 {page}/{pages} 页 · {commands} 条指令 · {plugins} 个子插件', en: 'Page {page}/{pages} · Commands: {commands} · Plugins: {plugins}' },
14
+ childPlugins: { 'zh-CN': '子插件:', en: 'Plugins:' },
15
+ emptyMenu: { 'zh-CN': '(暂无指令和子插件)', en: '(No commands or plugins)' },
16
+ pluginHint: { 'zh-CN': '提示:使用「{command} <插件名>」查看子插件的指令', en: 'Use {command} <plugin> to view a plugin’s commands.' },
17
+ previousPage: { 'zh-CN': '上一页:{command}', en: 'Previous page: {command}' },
18
+ nextPage: { 'zh-CN': '下一页:{command}', en: 'Next page: {command}' },
19
+ guideTitle: { 'zh-CN': '{title} · 指令指南', en: '{title} · Command guide' },
20
+ guideSubtitle: { 'zh-CN': '每一个灵感,都有回应。', en: 'An answer to every idea.' },
21
+ commandCount: { 'zh-CN': '{count} 条指令', en: 'Commands: {count}' },
22
+ pluginCount: { 'zh-CN': '{count} 个子插件', en: 'Plugins: {count}' },
23
+ commandIndex: { 'zh-CN': '指令索引', en: 'Commands' },
24
+ pluginIndex: { 'zh-CN': '插件导航', en: 'Plugins' },
25
+ guideEmpty: { 'zh-CN': '暂无指令和子插件', en: 'No commands or plugins' },
26
+ helpHint: { 'zh-CN': '查看参数与用法:{command} --help', en: 'View arguments and usage: {command} --help' },
27
+ genericHelpHint: { 'zh-CN': '在当前环境的指令后追加 --help 查看用法', en: 'Append --help to a command in this environment to view its usage' },
28
+ expectedType: { 'zh-CN': '{field}:应为 {type}', en: '{field}: expected {type}' },
29
+ expectedArray: { 'zh-CN': '{field}:应为 {type} 数组', en: '{field}: expected an array of {type}' },
30
+ minimum: { 'zh-CN': '{field}:最小值为 {minimum}', en: '{field}: minimum {minimum}' },
31
+ maximum: { 'zh-CN': '{field}:最大值为 {maximum}', en: '{field}: maximum {maximum}' },
32
+ choices: { 'zh-CN': '{field}:可选值为 {choices}', en: '{field}: expected one of {choices}' },
33
+ invalidFormat: { 'zh-CN': '{field}:格式不正确', en: '{field}: invalid format' },
34
+ invalidValue: { 'zh-CN': '{field}:值不正确', en: '{field}: invalid value' },
35
+ unknownOption: { 'zh-CN': '未知选项 {field}', en: 'Unknown option {field}' },
36
+ duplicateOption: { 'zh-CN': '重复选项 --{field}', en: 'Duplicate option --{field}' },
37
+ defaultUndefined: { 'zh-CN': '{field}:默认值返回了 undefined', en: '{field}: default returned undefined' },
38
+ parameterUndefined: { 'zh-CN': '{field}:默认值或快捷指令返回了 undefined', en: '{field}: default or shortcut returned undefined' },
39
+ missingOption: { 'zh-CN': '缺少选项 --{field}', en: 'Missing option --{field}' },
40
+ unexpectedArguments: { 'zh-CN': '存在多余参数', en: 'Unexpected arguments' },
41
+ invalidParameter: { 'zh-CN': '缺少指令参数或参数不正确', en: 'Missing or invalid command parameter' },
42
+ defaultValue: { 'zh-CN': '默认值:{value}', en: 'default: {value}' },
43
+ dynamicValue: { 'zh-CN': '动态计算', en: 'dynamic' },
44
+ noAdapter: { 'zh-CN': '命令未声明 adapter 约束', en: 'The command does not declare an adapter restriction' },
45
+ missingEndpoint: { 'zh-CN': '未选择当前 Endpoint,无法判断命令的精确 adapter 约束', en: 'Select an Endpoint to evaluate the command’s exact adapter restriction' },
46
+ adapterComparison: { 'zh-CN': '命令 adapter 与当前 Endpoint 声明的原生适配器精确比较;未探测在线状态', en: 'Exact comparison with the Endpoint’s declared native adapter; online status was not checked' },
47
+ conditionMismatch: { 'zh-CN': '至少一个声明条件与假设输入不符;这不是实际准入检查', en: 'At least one declared condition does not match the hypothetical input; this is not a live admission check' },
48
+ missingPermissionHost: { 'zh-CN': 'PermissionHost 缺失或无法确认;不能推断命令可执行', en: 'PermissionHost is missing or unverified; command execution cannot be inferred' },
49
+ missingEvidence: { 'zh-CN': '存在缺少依据的条件,不能推断命令可执行', en: 'Some conditions lack evidence; command execution cannot be inferred' },
50
+ noPermit: { 'zh-CN': '命令未声明 permit 限制;这不表示已获得执行权限', en: 'The command does not declare permit restrictions; this does not establish authorization' },
51
+ uncheckedConditions: { 'zh-CN': '存在未执行的平台或自定义条件,不能推断命令可执行', en: 'Platform or custom conditions have not been evaluated; command execution cannot be inferred' },
52
+ conditionsMatch: { 'zh-CN': '已解释的 builtin 条件与假设输入匹配;不代表真实授权或命令可执行', en: 'The explained builtin conditions match the hypothetical input; this does not establish authorization or executability' },
53
+ unconstrained: { 'zh-CN': '已解释的 builtin 规则不检查具体 Endpoint、parent 或 thread;保留完整目标不等于这些维度受规则限制', en: 'The explained builtin rules do not constrain a specific Endpoint, parent or thread; retaining the full target does not add such restrictions' },
54
+ simulatedIdentity: { 'zh-CN': '身份与角色仅用于模拟,未向平台核实', en: 'Identity and roles are hypothetical and have not been verified with the platform' },
55
+ unevaluatedCode: { 'zh-CN': '未执行命令、动态默认值、平台或自定义 checker', en: 'Commands, dynamic defaults, platform checks and custom checkers have not been executed' },
56
+ defaultedRole: { 'zh-CN': 'canonical 输入未附角色时按命令运行时默认 user 解释;这不是已核验的平台角色', en: 'Canonical input without roles uses the command runtime’s default user role; this is not a verified platform role' },
57
+ });
@@ -1,5 +1,6 @@
1
1
  import { type PermissionSubject, type PermissionConditionExplanation } from '@zhin.js/permission';
2
2
  import type { CommandDescriptor } from './command-index.js';
3
+ import { type Locale } from '@zhin.js/plugin-runtime';
3
4
  /** The exact subject projection used by the real command permit gate. No factories or checkers run. */
4
5
  export declare function toCommandPermissionSubject(source: unknown): PermissionSubject;
5
6
  /** Exact authoring adapter restriction, distinct from builtin adapter(...) matching. */
@@ -12,6 +13,7 @@ export declare function explainCommandPermissions(command: Pick<CommandDescripto
12
13
  source?: unknown;
13
14
  rolesKnown?: boolean;
14
15
  permissionHost: CommandPermissionHostStatus;
16
+ locale?: Locale;
15
17
  }>): Readonly<{
16
18
  permissionHost: CommandPermissionHostStatus;
17
19
  adapter: Readonly<PermissionConditionExplanation>;
@@ -1,6 +1,8 @@
1
1
  import { operationClientAdapter } from '@zhin.js/feature-kit';
2
2
  import { explainPermitRules, toPermissionSubject } from '@zhin.js/permission';
3
3
  import { resolveCommandSender, resolveCommandSession } from './definition.js';
4
+ import { resolveLocale } from '@zhin.js/plugin-runtime';
5
+ import { commandMessages } from './messages.js';
4
6
  /** The exact subject projection used by the real command permit gate. No factories or checkers run. */
5
7
  export function toCommandPermissionSubject(source) {
6
8
  const projected = toPermissionSubject(source);
@@ -13,6 +15,7 @@ export function commandAdapterMatches(command, source) {
13
15
  }
14
16
  /** Read-only hypothetical conditions. A match is not permission to execute the command. */
15
17
  export function explainCommandPermissions(command, options) {
18
+ const locale = resolveLocale(options.locale);
16
19
  const projected = toCommandPermissionSubject(options.source);
17
20
  const inputSender = options.source && typeof options.source === 'object'
18
21
  ? options.source.sender : undefined;
@@ -27,28 +30,28 @@ export function explainCommandPermissions(command, options) {
27
30
  && subject.sender?.role.length === 1 && subject.sender.role[0] === 'user';
28
31
  const actualAdapter = operationClientAdapter(options.source);
29
32
  const adapter = !command.adapter
30
- ? { outcome: 'not_evaluated', reason: '命令未声明 adapter 约束' }
31
- : !actualAdapter ? { outcome: 'unknown', reason: '未选择当前 Endpoint,无法判断命令的精确 adapter 约束' }
32
- : { outcome: commandAdapterMatches(command, options.source) ? 'match' : 'mismatch', reason: '命令 adapter 与当前 Endpoint 声明的原生适配器精确比较;未探测在线状态' };
33
- const rules = explainPermitRules(command.permit ?? [], subject, { rolesKnown: options.rolesKnown });
33
+ ? { outcome: 'not_evaluated', reason: commandMessages(locale, 'noAdapter') }
34
+ : !actualAdapter ? { outcome: 'unknown', reason: commandMessages(locale, 'missingEndpoint') }
35
+ : { outcome: commandAdapterMatches(command, options.source) ? 'match' : 'mismatch', reason: commandMessages(locale, 'adapterComparison') };
36
+ const rules = explainPermitRules(command.permit ?? [], subject, { rolesKnown: options.rolesKnown, locale });
34
37
  const conditions = [...(command.adapter ? [adapter] : []), ...rules];
35
38
  let summary;
36
39
  if (conditions.some(item => item.outcome === 'mismatch'))
37
- summary = { outcome: 'mismatch', reason: '至少一个声明条件与假设输入不符;这不是实际准入检查' };
40
+ summary = { outcome: 'mismatch', reason: commandMessages(locale, 'conditionMismatch') };
38
41
  else if ((rules.length > 0 && options.permissionHost !== 'present') || conditions.some(item => item.outcome === 'unknown')) {
39
42
  summary = { outcome: 'unknown', reason: rules.length > 0 && options.permissionHost !== 'present'
40
- ? 'PermissionHost 缺失或无法确认;不能推断命令可执行' : '存在缺少依据的条件,不能推断命令可执行' };
43
+ ? commandMessages(locale, 'missingPermissionHost') : commandMessages(locale, 'missingEvidence') };
41
44
  }
42
45
  else if (rules.length === 0)
43
- summary = { outcome: 'not_evaluated', reason: '命令未声明 permit 限制;这不表示已获得执行权限' };
46
+ summary = { outcome: 'not_evaluated', reason: commandMessages(locale, 'noPermit') };
44
47
  else if (conditions.some(item => item.outcome === 'not_evaluated'))
45
- summary = { outcome: 'not_evaluated', reason: '存在未执行的平台或自定义条件,不能推断命令可执行' };
48
+ summary = { outcome: 'not_evaluated', reason: commandMessages(locale, 'uncheckedConditions') };
46
49
  else
47
- summary = { outcome: 'match', reason: '已解释的 builtin 条件与假设输入匹配;不代表真实授权或命令可执行' };
50
+ summary = { outcome: 'match', reason: commandMessages(locale, 'conditionsMatch') };
48
51
  return Object.freeze({ permissionHost: options.permissionHost, adapter: Object.freeze(adapter), rules, summary: Object.freeze(summary),
49
- unconstrained: Object.freeze(['已解释的 builtin 规则不检查具体 Endpoint、parent 或 thread;保留完整目标不等于这些维度受规则限制']),
50
- assumptions: Object.freeze(['身份与角色仅用于模拟,未向平台核实', '未执行命令、动态默认值、平台或自定义 checker',
52
+ unconstrained: Object.freeze([commandMessages(locale, 'unconstrained')]),
53
+ assumptions: Object.freeze([commandMessages(locale, 'simulatedIdentity'), commandMessages(locale, 'unevaluatedCode'),
51
54
  ...(defaultedRoles
52
- ? ['canonical 输入未附角色时按命令运行时默认 user 解释;这不是已核验的平台角色'] : [])]),
55
+ ? [commandMessages(locale, 'defaultedRole')] : [])]),
53
56
  });
54
57
  }
package/lib/provider.js CHANGED
@@ -223,7 +223,7 @@ const commandFeature = defineFeatureProvider({
223
223
  const rawKeyword = rootConfig?.menuKeyword;
224
224
  const menu = rawKeyword === false || rawKeyword === ''
225
225
  ? undefined
226
- : { keyword: typeof rawKeyword === 'string' ? rawKeyword : '菜单' };
226
+ : typeof rawKeyword === 'string' ? { keyword: rawKeyword } : {};
227
227
  return { value: new CommandIndex(slots, context.snapshot, menu) };
228
228
  },
229
229
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/command",
3
- "version": "1.1.10",
3
+ "version": "1.1.11",
4
4
  "description": "Convention-based Command Feature for Zhin Plugin Runtime",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -21,10 +21,10 @@
21
21
  ],
22
22
  "dependencies": {
23
23
  "segment-matcher": "^1.0.5",
24
- "@zhin.js/feature-kit": "1.1.8",
24
+ "@zhin.js/feature-kit": "1.1.9",
25
25
  "@zhin.js/interaction": "1.1.2",
26
- "@zhin.js/permission": "1.1.8",
27
- "@zhin.js/plugin-runtime": "1.1.16"
26
+ "@zhin.js/permission": "1.1.9",
27
+ "@zhin.js/plugin-runtime": "1.1.17"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/node": "^26.1.2",
package/src/arguments.ts CHANGED
@@ -2,10 +2,30 @@ import type {
2
2
  CommandDefinition, CommandParams, CommandOptions, CommandParamSchema,
3
3
  CommandParameterValue, CommandSegment, CommandSession,
4
4
  } from './definition.js';
5
+ import type { Locale } from '@zhin.js/plugin-runtime';
6
+ import { commandDescription, commandMessages } from './messages.js';
7
+
8
+ export type CommandValidationIssueCode = 'expectedType' | 'expectedArray' | 'minimum' | 'maximum'
9
+ | 'choices' | 'invalidFormat' | 'invalidValue' | 'unknownOption' | 'duplicateOption'
10
+ | 'defaultUndefined' | 'parameterUndefined' | 'missingOption' | 'unexpectedArguments'
11
+ | 'invalidParameter' | 'custom';
5
12
 
6
13
  export interface CommandValidationIssue {
7
14
  readonly field: string;
8
15
  readonly message: string;
16
+ readonly code?: CommandValidationIssueCode;
17
+ readonly parameters?: Readonly<Record<string, string | number>>;
18
+ }
19
+
20
+ /** Stable validation reason and values; never reverse-parse an English message. */
21
+ export function commandValidationIssue(
22
+ code: Exclude<CommandValidationIssueCode, 'custom'>,
23
+ field: string,
24
+ locale: Locale,
25
+ parameters: Readonly<Record<string, string | number>> = {},
26
+ ): CommandValidationIssue {
27
+ const values = Object.freeze({ field, ...parameters });
28
+ return Object.freeze({ field, code, parameters: values, message: commandMessages(locale, code, values) });
9
29
  }
10
30
  export interface ParsedCommandArguments {
11
31
  readonly params: Readonly<Record<string, CommandParameterValue>>;
@@ -63,6 +83,11 @@ function validateSchema(name: string, schema: CommandParamSchema): void {
63
83
  if (schema.choices !== undefined && !Array.isArray(schema.choices)) {
64
84
  throw new TypeError(`Command argument ${name}.choices must be an array`);
65
85
  }
86
+ if (schema.descriptionI18n !== undefined && (!schema.descriptionI18n || typeof schema.descriptionI18n !== 'object'
87
+ || Array.isArray(schema.descriptionI18n)
88
+ || Object.entries(schema.descriptionI18n).some(([locale, text]) => !['zh-CN', 'en'].includes(locale) || typeof text !== 'string'))) {
89
+ throw new TypeError(`Command argument ${name}.descriptionI18n must map zh-CN/en to strings`);
90
+ }
66
91
  if (schema.default !== undefined && !scalarTypes.has(schema.type)) throw new TypeError(`Structured Command argument ${name} cannot declare a default`);
67
92
  if (schema.default !== undefined && typeof schema.default !== 'function') {
68
93
  if (Array.isArray(schema.default) && !schema.rest) throw new TypeError(`Command argument ${name} requires rest: true for an array default`);
@@ -161,22 +186,23 @@ function valueMatches(schema: CommandParamSchema, value: unknown): boolean {
161
186
  if (schema.type === 'image') return typeof value === 'string' || !!value && typeof value === 'object';
162
187
  return typeof value === 'string';
163
188
  }
164
- function validateValue(field: string, schema: CommandParamSchema, value: CommandParameterValue, session: CommandSession): CommandValidationIssue | undefined {
165
- if (schema.rest && !Array.isArray(value)) return { field, message: `${field}: expected an array of ${schema.type}` };
189
+ function validateValue(field: string, schema: CommandParamSchema, value: CommandParameterValue, session: CommandSession, locale: Locale = 'zh-CN'): CommandValidationIssue | undefined {
190
+ if (schema.rest && !Array.isArray(value)) return commandValidationIssue('expectedArray', field, locale, { type: schema.type });
166
191
  const values = schema.rest ? (Array.isArray(value) ? value : [value]) : [value];
167
192
  for (const item of values) {
168
- if (!valueMatches(schema, item)) return { field, message: `${field}: expected ${schema.type}` };
193
+ if (!valueMatches(schema, item)) return commandValidationIssue('expectedType', field, locale, { type: schema.type });
169
194
  const size = typeof item === 'number' ? item : typeof item === 'string' ? item.length : undefined;
170
- if (schema.min !== undefined && (size === undefined || size < schema.min)) return { field, message: `${field}: minimum ${schema.min}` };
171
- if (schema.max !== undefined && (size === undefined || size > schema.max)) return { field, message: `${field}: maximum ${schema.max}` };
172
- if (schema.choices && !schema.choices.includes(item)) return { field, message: `${field}: expected one of ${schema.choices.join(', ')}` };
195
+ if (schema.min !== undefined && (size === undefined || size < schema.min)) return commandValidationIssue('minimum', field, locale, { minimum: schema.min });
196
+ if (schema.max !== undefined && (size === undefined || size > schema.max)) return commandValidationIssue('maximum', field, locale, { maximum: schema.max });
197
+ if (schema.choices && !schema.choices.includes(item)) return commandValidationIssue('choices', field, locale, { choices: schema.choices.join(', ') });
173
198
  if (schema.pattern) {
174
199
  const pattern = new RegExp(schema.pattern.source, schema.pattern.flags.replace(/[gy]/gu, ''));
175
- if (typeof item !== 'string' || !pattern.test(item)) return { field, message: `${field}: invalid format` };
200
+ if (typeof item !== 'string' || !pattern.test(item)) return commandValidationIssue('invalidFormat', field, locale);
176
201
  }
177
202
  }
178
203
  const result = schema.validate?.(value, session);
179
- return result === false || typeof result === 'string' ? { field, message: typeof result === 'string' ? result : `${field}: invalid value` } : undefined;
204
+ return typeof result === 'string' ? Object.freeze({ field, code: 'custom', message: result })
205
+ : result === false ? commandValidationIssue('invalidValue', field, locale) : undefined;
180
206
  }
181
207
 
182
208
  /** Parse arguments after a route match; the CommandIndex remains the only dispatcher. */
@@ -185,6 +211,7 @@ export function parseCommandArguments(
185
211
  remaining: readonly Readonly<CommandSegment>[],
186
212
  initial: Readonly<Record<string, CommandParameterValue>>,
187
213
  session: CommandSession,
214
+ locale: Locale = 'zh-CN',
188
215
  ): ParsedCommandArguments {
189
216
  const params: Record<string, CommandParameterValue> = { __proto__: null, ...initial };
190
217
  const options: Record<string, CommandParameterValue> = { __proto__: null };
@@ -203,10 +230,10 @@ export function parseCommandArguments(
203
230
  const [rawFlag, inline] = token.value.split(/=(.*)/u);
204
231
  const flag = rawFlag ?? token.value;
205
232
  const name = optionNames.get(flag);
206
- if (!name) { issues.push({ field: flag, message: `Unknown option ${flag}` }); continue; }
233
+ if (!name) { issues.push(commandValidationIssue('unknownOption', flag, locale)); continue; }
207
234
  const schema = schemas[name];
208
235
  if (!schema) continue;
209
- if (Object.hasOwn(options, name)) { issues.push({ field: name, message: `Duplicate option --${name}` }); continue; }
236
+ if (Object.hasOwn(options, name)) { issues.push(commandValidationIssue('duplicateOption', name, locale)); continue; }
210
237
  let value: CommandParameterValue | undefined;
211
238
  if (inline !== undefined) value = convert(schema, { value: inline, quoted: false });
212
239
  else if (schema.type === 'boolean') value = true;
@@ -214,14 +241,14 @@ export function parseCommandArguments(
214
241
  const next = tokens[index + 1];
215
242
  if (isTextToken(next) && !(!next.quoted && /^--?[a-zA-Z]/u.test(next.value))) { index++; value = convert(schema, next); }
216
243
  }
217
- if (value === undefined) issues.push({ field: name, message: `--${name}: expected ${schema.type}` });
244
+ if (value === undefined) issues.push(commandValidationIssue('expectedType', name, locale, { field: `--${name}`, type: schema.type }));
218
245
  else options[name] = value;
219
246
  }
220
247
  for (const [name, schema] of Object.entries(schemas ?? {})) {
221
248
  if (!Object.hasOwn(options, name) && schema.default !== undefined) options[name] = typeof schema.default === 'function' ? schema.default(session) : schema.default;
222
- if (Object.hasOwn(options, name) && options[name] === undefined) issues.push({ field: name, message: `--${name}: default returned undefined` });
223
- else if (options[name] !== undefined) { const issue = validateValue(`--${name}`, schema, options[name], session); if (issue) issues.push(issue); }
224
- else if (schema.optional === false) issues.push({ field: name, message: `Missing option --${name}` });
249
+ if (Object.hasOwn(options, name) && options[name] === undefined) issues.push(commandValidationIssue('defaultUndefined', name, locale, { field: `--${name}` }));
250
+ else if (options[name] !== undefined) { const issue = validateValue(`--${name}`, schema, options[name], session, locale); if (issue) issues.push(issue); }
251
+ else if (schema.optional === false) issues.push(commandValidationIssue('missingOption', name, locale));
225
252
  }
226
253
  const declared = Object.entries(definition.params ?? {});
227
254
  for (const [name, declaredSchema] of declared) {
@@ -233,7 +260,7 @@ export function parseCommandArguments(
233
260
  const converted = schema.type === 'text'
234
261
  ? segmentsFromTokens(raw).map((segment) => segment.type === 'text' && typeof segment.data.text === 'string' ? segment.data.text : undefined)
235
262
  : raw.map((token) => convert(schema, token));
236
- if (converted.some((item) => item === undefined)) issues.push({ field: name, message: `${name}: expected ${schema.type}` });
263
+ if (converted.some((item) => item === undefined)) issues.push(commandValidationIssue('expectedType', name, locale, { type: schema.type }));
237
264
  else if (converted.length > 0 || schema.optional) value = converted as readonly (string | number | boolean | Readonly<Record<string, unknown>>)[];
238
265
  } else if (schema.type === 'text') {
239
266
  const raw: string[] = [];
@@ -246,20 +273,20 @@ export function parseCommandArguments(
246
273
  value = convert(schema, positional.shift());
247
274
  // Defaults apply to omitted input, never to a supplied value of the wrong type.
248
275
  if (value === undefined) {
249
- issues.push({ field: name, message: `${name}: expected ${schema.type}` });
276
+ issues.push(commandValidationIssue('expectedType', name, locale, { type: schema.type }));
250
277
  continue;
251
278
  }
252
279
  }
253
280
  if (value !== undefined) params[name] = value;
254
281
  else if (schema.default !== undefined) params[name] = typeof schema.default === 'function' ? schema.default(session) : schema.default;
255
282
  else if (!schema.optional && !(definition.$parameter?.name === name && definition.$parameter.optional)) {
256
- issues.push({ field: name, message: `${name}: expected ${schema.type}` });
283
+ issues.push(commandValidationIssue('expectedType', name, locale, { type: schema.type }));
257
284
  }
258
285
  }
259
- if (Object.hasOwn(params, name) && params[name] === undefined) issues.push({ field: name, message: `${name}: default or shortcut returned undefined` });
260
- else if (params[name] !== undefined) { const effective = definition.$parameter?.name === name && definition.$parameter.rest ? { ...schema, rest: true } : schema; const issue = validateValue(name, effective, params[name], session); if (issue) issues.push(issue); }
286
+ if (Object.hasOwn(params, name) && params[name] === undefined) issues.push(commandValidationIssue('parameterUndefined', name, locale));
287
+ else if (params[name] !== undefined) { const effective = definition.$parameter?.name === name && definition.$parameter.rest ? { ...schema, rest: true } : schema; const issue = validateValue(name, effective, params[name], session, locale); if (issue) issues.push(issue); }
261
288
  }
262
- if (declared.length > 0 && !definition.$parameter && positional.length > 0) issues.push({ field: 'arguments', message: 'Unexpected arguments' });
289
+ if (declared.length > 0 && !definition.$parameter && positional.length > 0) issues.push(commandValidationIssue('unexpectedArguments', 'arguments', locale));
263
290
  return Object.freeze({ params: Object.freeze(params), options: Object.freeze(options), remaining: schemas === undefined && declared.every(([name]) => Object.hasOwn(initial, name)) ? remaining : segmentsFromTokens(positional), issues: Object.freeze(issues) });
264
291
  }
265
292
 
@@ -269,12 +296,13 @@ export function commandUsage(name: string, definition: CommandDefinition): strin
269
296
  const options = Object.entries(definition.options ?? {}).map(([key, schema]) => `[--${key}${schema.type === 'boolean' ? '' : ` <${schema.type}>`}]`);
270
297
  return [name, ...parameters, ...options].join(' ');
271
298
  }
272
- export function commandHelp(name: string, definition: CommandDefinition, prefix = ''): string {
273
- return [prefix + commandUsage(name, definition), definition.description,
274
- ...Object.entries(definition.params ?? {}).map(([key, schema]) => argumentHelp(key, schema)),
275
- ...Object.entries(definition.options ?? {}).map(([key, schema]) => argumentHelp(`--${key}${schema.alias ? ` (-${schema.alias})` : ''}`, schema)),
299
+ export function commandHelp(name: string, definition: CommandDefinition, prefix = '', locale: Locale = 'zh-CN'): string {
300
+ return [prefix + commandUsage(name, definition), commandDescription(definition, locale),
301
+ ...Object.entries(definition.params ?? {}).map(([key, schema]) => argumentHelp(key, schema, locale)),
302
+ ...Object.entries(definition.options ?? {}).map(([key, schema]) => argumentHelp(`--${key}${schema.alias ? ` (-${schema.alias})` : ''}`, schema, locale)),
276
303
  ].filter(Boolean).join('\n');
277
304
  }
278
- function argumentHelp(name: string, schema: CommandParamSchema): string {
279
- return ` ${name}: ${schema.type}${schema.description ? ` — ${schema.description}` : ''}${schema.default !== undefined ? ` (default: ${typeof schema.default === 'function' ? 'dynamic' : JSON.stringify(schema.default)})` : ''}${schema.choices ? ` (${schema.choices.join(', ')})` : ''}${schema.min !== undefined ? ` min=${schema.min}` : ''}${schema.max !== undefined ? ` max=${schema.max}` : ''}`;
305
+ function argumentHelp(name: string, schema: CommandParamSchema, locale: Locale): string {
306
+ const description = commandDescription(schema, locale);
307
+ return ` ${name}: ${schema.type}${description ? ` — ${description}` : ''}${schema.default !== undefined ? ` (${commandMessages(locale, 'defaultValue', { value: typeof schema.default === 'function' ? commandMessages(locale, 'dynamicValue') : JSON.stringify(schema.default) })})` : ''}${schema.choices ? ` (${schema.choices.join(', ')})` : ''}${schema.min !== undefined ? ` min=${schema.min}` : ''}${schema.max !== undefined ? ` max=${schema.max}` : ''}`;
280
308
  }