@nectar-js/nectar 0.1.0 → 0.3.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.
@@ -1,6 +1,6 @@
1
- import { T as version, _ as parseSegment, a as MANIFEST_FILE, b as loadModule, c as writeManifest, d as checkDeclaredRoute, f as compileComponents, g as formatSegment, i as pluginGraph, o as stableStringify, r as applyPlugins, s as toManifest, t as PluginError, v as enableModuleReloading, x as Diagnostics, y as invalidateModuleGraph } from "./plugins-CGvM19v9.js";
2
- import { a as checkIntents, c as configFor, i as scopeKey, n as syncCommands, r as RegistrationError, s as ConfigError, t as UnsafeSyncError, u as validateConfig } from "./registration-CaE0QBT6.js";
3
- import { i as createLogger, l as createSignals, n as createRuntime, r as manifestFiles, s as HandlerLoadError, t as LoginError, u as loadManifest } from "./runtime-CZJeZvSL.js";
1
+ import { C as typeOf, D as version, S as docsUrl, _ as parseSegment, a as MANIFEST_FILE, b as loadModule, c as writeManifest, d as checkHandler, f as compileComponents, g as formatSegment, i as pluginGraph, o as stableStringify, r as applyPlugins, s as toManifest, t as PluginError, v as enableModuleReloading, x as Diagnostics, y as invalidateModuleGraph } from "./plugins-C2uwN3-D.js";
2
+ import { a as checkIntents, c as configFor, i as scopeKey, n as syncCommands, r as RegistrationError, s as ConfigError, t as UnsafeSyncError, u as validateConfig } from "./registration-mUZJidnF.js";
3
+ import { d as loadManifest, i as createLogger, l as createSignals, n as createRuntime, r as manifestFiles, s as HandlerLoadError, t as LoginError, u as optionsAt } from "./runtime-B7mCDO5Q.js";
4
4
  import path from "node:path";
5
5
  import { createHash } from "node:crypto";
6
6
  import { existsSync, mkdirSync, readdirSync, rmSync, watch, writeFileSync } from "node:fs";
@@ -27,17 +27,6 @@ const COMMAND_TYPE$1 = {
27
27
  [ApplicationCommandType.User]: "user",
28
28
  [ApplicationCommandType.Message]: "message"
29
29
  };
30
- const OPTION_TYPE$1 = {
31
- [ApplicationCommandOptionType.String]: "string",
32
- [ApplicationCommandOptionType.Integer]: "integer",
33
- [ApplicationCommandOptionType.Number]: "number",
34
- [ApplicationCommandOptionType.Boolean]: "boolean",
35
- [ApplicationCommandOptionType.User]: "user",
36
- [ApplicationCommandOptionType.Channel]: "channel",
37
- [ApplicationCommandOptionType.Role]: "role",
38
- [ApplicationCommandOptionType.Mentionable]: "mentionable",
39
- [ApplicationCommandOptionType.Attachment]: "attachment"
40
- };
41
30
  /**
42
31
  * Renders `types.d.ts`: a module augmentation of `@nectar-js/nectar` that lists every route with
43
32
  * its parameters, options, and the context its middleware chain adds. Handler and middleware
@@ -61,7 +50,7 @@ function toTypes(graph, outDir, plugins = []) {
61
50
  };
62
51
  const commands = [];
63
52
  for (const command of [...graph.commands].sort((a, b) => a.name.localeCompare(b.name))) for (const [key, route] of Object.entries(command.handlers)) {
64
- const options = Object.entries(optionsAt(command.payload, key)).map(([name, type]) => `${quote(name)}: ${quote(type)}`).join("; ");
53
+ const options = optionsAt(command.payload, key).map((o) => `${quote(o.name)}: { type: ${quote(o.type)}; required: ${o.required} }`).join("; ");
65
54
  commands.push(` ${quote(route.path)}: { type: ${quote(COMMAND_TYPE$1[command.type] ?? "chatInput")}; options: {${options === "" ? "" : ` ${options} `}}; context: ${contextOf(route)} };`);
66
55
  }
67
56
  commands.sort();
@@ -122,17 +111,6 @@ function writeTypes(graph, outDir, plugins = []) {
122
111
  writeFileSync(file, toTypes(graph, outDir, plugins));
123
112
  return file;
124
113
  }
125
- /** Option name to option type name for one handler position of a command payload. */
126
- function optionsAt(payload, key) {
127
- let options = payload.options;
128
- for (const part of key === "" ? [] : key.split("/")) options = options?.find((o) => o.name === part)?.options;
129
- const out = {};
130
- for (const option of options ?? []) {
131
- const type = option.type === void 0 ? void 0 : OPTION_TYPE$1[option.type];
132
- if (option.name !== void 0 && type !== void 0) out[option.name] = type;
133
- }
134
- return out;
135
- }
136
114
  /** `../app/middleware.ts` becomes `../app/middleware.js`, which NodeNext resolves back to the source. */
137
115
  function importPath(fromDir, file) {
138
116
  const mapped = path.relative(fromDir, file).split(path.sep).join("/").replace(/\.mts$/, ".mjs").replace(/\.ts$/, ".js");
@@ -162,7 +140,8 @@ async function compileAutocomplete(table, commands) {
162
140
  const results = await Promise.all(routes.map(async (route) => {
163
141
  const target = targets.get(route.id);
164
142
  if (target === void 0) {
165
- diagnostics.error("autocomplete-without-command", `autocomplete.ts needs a command.ts in the same directory. None was found for "${route.path}".`, {
143
+ if (table.routes.some((r) => r.id === route.id && r.kind === "command")) return null;
144
+ diagnostics.error("autocomplete-without-command", "There's no command.ts next to this autocomplete.ts. Autocomplete answers the options of the command in the same directory, so move it next to that command.ts.", {
166
145
  file: route.file,
167
146
  route: route.id
168
147
  });
@@ -172,7 +151,7 @@ async function compileAutocomplete(table, commands) {
172
151
  try {
173
152
  module = await loadModule(route.file);
174
153
  } catch (error) {
175
- diagnostics.error("module-load-failed", `Could not import this file: ${error instanceof Error ? error.message : String(error)}`, {
154
+ diagnostics.error("module-load-failed", `The compiler imports every route file to read its exports, and this one threw: ${error instanceof Error ? error.message : String(error)}`, {
176
155
  file: route.file,
177
156
  route: route.id
178
157
  });
@@ -182,7 +161,7 @@ async function compileAutocomplete(table, commands) {
182
161
  let ok = true;
183
162
  for (const name of exported) {
184
163
  if (typeof module[name] !== "function") {
185
- diagnostics.error("autocomplete-export-not-function", `Export "${name}" must be a function that answers autocomplete for the "${name}" option.`, {
164
+ diagnostics.error("autocomplete-export-not-function", `Export "${name}" isn't a function. Each export of autocomplete.ts is a function that answers the option with the same name.`, {
186
165
  file: route.file,
187
166
  route: route.id
188
167
  });
@@ -190,7 +169,7 @@ async function compileAutocomplete(table, commands) {
190
169
  continue;
191
170
  }
192
171
  if (!target.options.has(name)) {
193
- diagnostics.error("autocomplete-unknown-option", `Export "${name}" does not match an option with \`autocomplete: true\` in ${relative$2(target.command.file)}. ${expected(target.options)}`, {
172
+ diagnostics.error("autocomplete-unknown-option", `Export "${name}" doesn't match an option with autocomplete: true in ${relative$3(target.command.file)}. ${expected(target.options)} Rename the export, or set autocomplete: true on the option.`, {
194
173
  file: route.file,
195
174
  route: route.id
196
175
  });
@@ -199,7 +178,7 @@ async function compileAutocomplete(table, commands) {
199
178
  }
200
179
  for (const name of [...target.options].sort()) {
201
180
  if (exported.includes(name)) continue;
202
- diagnostics.error("autocomplete-missing-handler", `Option "${name}" has \`autocomplete: true\` but ${relative$2(route.file)} does not export a "${name}" function.`, {
181
+ diagnostics.error("autocomplete-missing-handler", `Option "${name}" has autocomplete: true, but this file doesn't export a function named "${name}" to answer it.`, {
203
182
  file: route.file,
204
183
  route: route.id
205
184
  });
@@ -213,7 +192,8 @@ async function compileAutocomplete(table, commands) {
213
192
  }));
214
193
  for (const [id, target] of targets) {
215
194
  if (target.options.size === 0 || routes.some((r) => r.id === id)) continue;
216
- diagnostics.error("autocomplete-missing-file", `${[...target.options].map((o) => `"${o}"`).join(", ")} ${target.options.size === 1 ? "has" : "have"} \`autocomplete: true\` but there is no autocomplete.ts next to this command.`, {
195
+ const names = [...target.options].map((o) => `"${o}"`);
196
+ diagnostics.error("autocomplete-missing-file", names.length === 1 ? `Option ${names[0]} has autocomplete: true, but there's no autocomplete.ts next to this command. Add one that exports a function named ${names[0]}.` : `Options ${new Intl.ListFormat("en").format(names)} have autocomplete: true, but there's no autocomplete.ts next to this command. Add one that exports a function named after each of them.`, {
217
197
  file: target.command.file,
218
198
  route: id
219
199
  });
@@ -230,9 +210,11 @@ function autocompleteOptions(command, key) {
230
210
  return new Set((options ?? []).filter((o) => o.autocomplete === true).map((o) => o.name));
231
211
  }
232
212
  function expected(options) {
233
- return options.size === 0 ? "The command declares no autocomplete options." : `Expected one of: ${[...options].sort().join(", ")}.`;
213
+ const names = [...options].sort().map((o) => `"${o}"`);
214
+ if (names.length === 0) return "That command has no autocomplete options.";
215
+ return names.length === 1 ? `Its autocomplete option is ${names[0]}.` : `Its autocomplete options are ${new Intl.ListFormat("en").format(names)}.`;
234
216
  }
235
- function relative$2(file) {
217
+ function relative$3(file) {
236
218
  return path.relative(process.cwd(), file).split(path.sep).join("/");
237
219
  }
238
220
  //#endregion
@@ -270,27 +252,28 @@ function validateCommandMeta(value, file, diagnostics) {
270
252
  file
271
253
  };
272
254
  if (value === void 0) {
273
- diagnostics.error("missing-meta", "command.ts must export a `meta` object with at least a description.", { file });
255
+ diagnostics.error("missing-meta", "This command.ts doesn't export meta. Discord needs a description for every slash command, so add export const meta = { description: \"...\" }. A context menu command sets type instead, like { type: \"user\" }.", { file });
274
256
  return null;
275
257
  }
276
258
  if (!isRecord(value)) {
277
- fail$1(ctx, "invalid-meta", "`meta` must be an object.");
259
+ fail$1(ctx, "invalid-meta", `meta is ${typeOf(value)}. Export an object, like { description: "..." }.`);
278
260
  return null;
279
261
  }
280
262
  let ok = true;
281
263
  const type = value.type ?? "chatInput";
282
- if (typeof type !== "string" || !COMMAND_TYPES.has(type)) ok = fail$1(ctx, "invalid-meta", "`meta.type` must be \"chatInput\", \"user\", or \"message\".");
264
+ if (typeof type !== "string" || !COMMAND_TYPES.has(type)) ok = fail$1(ctx, "invalid-meta", `meta.type is ${JSON.stringify(type)}. Use "chatInput" for a slash command, or "user" or "message" for a context menu command.`);
283
265
  if (value.name !== void 0) ok = checkName(ctx, value.name, "meta.name", type === "chatInput") && ok;
284
266
  if (type === "chatInput") {
285
267
  ok = checkDescription(ctx, value.description, "meta.description") && ok;
286
268
  if (value.options !== void 0) ok = checkOptions(ctx, value.options) && ok;
287
269
  } else {
288
- if (value.description !== void 0 && value.description !== "") ok = fail$1(ctx, "invalid-meta", "Context menu commands cannot have a description. Remove `meta.description`.");
289
- if (value.options !== void 0) ok = fail$1(ctx, "invalid-meta", "Context menu commands cannot have options.");
270
+ if (value.description !== void 0 && value.description !== "") ok = fail$1(ctx, "invalid-meta", "Discord doesn't allow a description on context menu commands. Remove meta.description.");
271
+ if (value.options !== void 0) ok = fail$1(ctx, "invalid-meta", "Discord doesn't allow options on context menu commands. Remove meta.options.");
290
272
  }
291
273
  ok = checkLocalizations(ctx, value.nameLocalizations, "meta.nameLocalizations") && ok;
292
274
  ok = checkLocalizations(ctx, value.descriptionLocalizations, "meta.descriptionLocalizations") && ok;
293
275
  ok = checkTopLevel(ctx, value) && ok;
276
+ if (value.defer !== void 0 && typeof value.defer !== "boolean" && value.defer !== "ephemeral") ok = fail$1(ctx, "invalid-meta", `meta.defer is ${JSON.stringify(value.defer)}. Use true to defer the reply before the handler runs, or "ephemeral" to defer it as an ephemeral reply.`);
294
277
  return ok ? value : null;
295
278
  }
296
279
  /** Validates the `meta` export of a `route.ts` under `commands/`. */
@@ -300,11 +283,11 @@ function validateCommandRouteMeta(value, file, diagnostics) {
300
283
  file
301
284
  };
302
285
  if (value === void 0) {
303
- diagnostics.error("missing-meta", "route.ts must export a `meta` object with a description.", { file });
286
+ diagnostics.error("missing-meta", "This route.ts doesn't export meta. It holds the description Discord shows for the command or subcommand group, so add export const meta = { description: \"...\" }.", { file });
304
287
  return null;
305
288
  }
306
289
  if (!isRecord(value)) {
307
- fail$1(ctx, "invalid-meta", "`meta` must be an object.");
290
+ fail$1(ctx, "invalid-meta", `meta is ${typeOf(value)}. Export an object, like { description: "..." }.`);
308
291
  return null;
309
292
  }
310
293
  let ok = checkDescription(ctx, value.description, "meta.description");
@@ -322,91 +305,105 @@ function fail$1(ctx, code, message) {
322
305
  ctx.diagnostics.error(code, message, { file: ctx.file });
323
306
  return false;
324
307
  }
308
+ /** What a text field holds, for length errors: `empty`, `140 characters`, or its type. */
309
+ function sizeOf(value) {
310
+ if (typeof value !== "string") return typeOf(value);
311
+ return value === "" ? "empty" : `${value.length} characters`;
312
+ }
325
313
  function checkName(ctx, name, label, chatInput) {
326
- if (typeof name !== "string" || name.length === 0 || name.length > 32) return fail$1(ctx, "invalid-name", `${label} must be a string of 1 to 32 characters.`);
314
+ if (typeof name !== "string" || name.length === 0 || name.length > 32) return fail$1(ctx, "invalid-name", `${label} is ${sizeOf(name)}. Discord needs a name of 1 to 32 characters.`);
327
315
  if (chatInput) {
328
- if (!COMMAND_NAME.test(name) || name !== name.toLowerCase()) return fail$1(ctx, "invalid-name", `${label} "${name}" is not a valid chat input command name. Discord requires lowercase letters, digits, hyphens, and underscores.`);
316
+ if (!COMMAND_NAME.test(name) || name !== name.toLowerCase()) return fail$1(ctx, "invalid-name", `${label} is "${name}". Discord only allows lowercase letters, digits, hyphens, and underscores in slash command and option names.`);
329
317
  }
330
318
  return true;
331
319
  }
332
320
  function checkDescription(ctx, description, label) {
333
- if (typeof description !== "string" || description.length === 0 || description.length > 100) return fail$1(ctx, "invalid-description", `${label} must be a string of 1 to 100 characters.`);
321
+ if (typeof description !== "string" || description.length === 0 || description.length > 100) return fail$1(ctx, "invalid-description", `${label} is ${sizeOf(description)}. Discord needs a description of 1 to 100 characters.`);
334
322
  return true;
335
323
  }
336
324
  function checkLocalizations(ctx, value, label) {
337
325
  if (value === void 0) return true;
338
- if (!isRecord(value)) return fail$1(ctx, "invalid-meta", `${label} must be an object of locale to string.`);
339
- for (const [locale, text] of Object.entries(value)) if (typeof text !== "string") return fail$1(ctx, "invalid-meta", `${label}.${locale} must be a string.`);
326
+ if (!isRecord(value)) return fail$1(ctx, "invalid-meta", `${label} is ${typeOf(value)}. Use an object of locale to text, like { fr: "..." }.`);
327
+ for (const [locale, text] of Object.entries(value)) if (typeof text !== "string") return fail$1(ctx, "invalid-meta", `${label}.${locale} is ${typeOf(text)}, not a string.`);
340
328
  return true;
341
329
  }
342
330
  function checkTopLevel(ctx, value) {
343
331
  let ok = true;
344
332
  const perms = value.defaultMemberPermissions;
345
- if (perms !== void 0 && perms !== null && typeof perms !== "bigint" && typeof perms !== "string" && typeof perms !== "number") ok = fail$1(ctx, "invalid-meta", "`meta.defaultMemberPermissions` must be a permission bitfield (bigint, string, or number) or null.");
346
- if (value.nsfw !== void 0 && typeof value.nsfw !== "boolean") ok = fail$1(ctx, "invalid-meta", "`meta.nsfw` must be a boolean.");
347
- ok = checkEnumArray(ctx, value.contexts, "meta.contexts", [
333
+ if (perms !== void 0 && perms !== null && typeof perms !== "bigint" && typeof perms !== "string" && typeof perms !== "number") ok = fail$1(ctx, "invalid-meta", `meta.defaultMemberPermissions is ${typeOf(perms)}. Use a permission bitfield, like PermissionFlagsBits.BanMembers from discord.js, or null.`);
334
+ if (value.nsfw !== void 0 && typeof value.nsfw !== "boolean") ok = fail$1(ctx, "invalid-meta", `meta.nsfw is ${typeOf(value.nsfw)}. Use true or false.`);
335
+ ok = checkEnumArray(ctx, value.contexts, "meta.contexts", "InteractionContextType", [
348
336
  0,
349
337
  1,
350
338
  2
351
339
  ]) && ok;
352
- ok = checkEnumArray(ctx, value.integrationTypes, "meta.integrationTypes", [0, 1]) && ok;
340
+ ok = checkEnumArray(ctx, value.integrationTypes, "meta.integrationTypes", "ApplicationIntegrationType", [0, 1]) && ok;
353
341
  return ok;
354
342
  }
355
- function checkEnumArray(ctx, value, label, allowed) {
343
+ function checkEnumArray(ctx, value, label, enumName, allowed) {
356
344
  if (value === void 0) return true;
357
- if (!Array.isArray(value) || value.some((v) => !allowed.includes(v))) return fail$1(ctx, "invalid-meta", `${label} must be an array of ${allowed.join(", ")}. Use the discord.js enum values.`);
345
+ if (!Array.isArray(value) || value.some((v) => !allowed.includes(v))) return fail$1(ctx, "invalid-meta", `${label} has to be an array of ${enumName} values from discord.js.`);
358
346
  return true;
359
347
  }
360
348
  function checkOptions(ctx, options) {
361
- if (!Array.isArray(options)) return fail$1(ctx, "invalid-option", "`meta.options` must be an array.");
362
- if (options.length > 25) return fail$1(ctx, "invalid-option", "A command can have at most 25 options.");
349
+ if (!Array.isArray(options)) return fail$1(ctx, "invalid-option", `meta.options is ${typeOf(options)}, not an array.`);
350
+ if (options.length > 25) return fail$1(ctx, "invalid-option", `meta.options has ${options.length} options. Discord allows 25 per command.`);
363
351
  let ok = true;
364
352
  const names = /* @__PURE__ */ new Set();
365
353
  let seenOptional = false;
366
354
  for (const [index, option] of options.entries()) {
367
355
  const label = `meta.options[${index}]`;
368
356
  if (!isRecord(option)) {
369
- ok = fail$1(ctx, "invalid-option", `${label} must be an object.`);
357
+ ok = fail$1(ctx, "invalid-option", `${label} is ${typeOf(option)}, not an object.`);
370
358
  continue;
371
359
  }
372
360
  const typedOk = checkOption(ctx, option, label);
373
361
  ok = typedOk && ok;
374
362
  if (!typedOk) continue;
375
363
  const typed = option;
376
- if (names.has(typed.name)) ok = fail$1(ctx, "invalid-option", `${label}: option name "${typed.name}" is used twice.`);
364
+ if (names.has(typed.name)) ok = fail$1(ctx, "invalid-option", `${label} reuses the name "${typed.name}". Discord needs option names to be unique within a command.`);
377
365
  names.add(typed.name);
378
366
  if (typed.required) {
379
- if (seenOptional) ok = fail$1(ctx, "invalid-option", `${label}: required option "${typed.name}" comes after an optional one. Discord requires all required options first.`);
367
+ if (seenOptional) ok = fail$1(ctx, "invalid-option", `${label}, "${typed.name}", is required but comes after an optional option. Discord needs required options first, so move it up.`);
380
368
  } else seenOptional = true;
381
369
  }
382
370
  return ok;
383
371
  }
372
+ /** The option types each type-specific field works on. */
373
+ const FIELD_TYPES = {
374
+ choices: [
375
+ "string",
376
+ "integer",
377
+ "number"
378
+ ],
379
+ autocomplete: [
380
+ "string",
381
+ "integer",
382
+ "number"
383
+ ],
384
+ minLength: ["string"],
385
+ maxLength: ["string"],
386
+ minValue: ["integer", "number"],
387
+ maxValue: ["integer", "number"],
388
+ channelTypes: ["channel"]
389
+ };
390
+ const quoted = (items, type) => new Intl.ListFormat("en", { type }).format([...items].map((item) => `"${item}"`));
384
391
  function checkOption(ctx, option, label) {
385
392
  let ok = true;
386
- if (typeof option.type !== "string" || !OPTION_TYPES.has(option.type)) return fail$1(ctx, "invalid-option", `${label}.type must be one of ${[...OPTION_TYPES].map((t) => `"${t}"`).join(", ")}.`);
393
+ if (typeof option.type !== "string" || !OPTION_TYPES.has(option.type)) return fail$1(ctx, "invalid-option", `${label}.type is ${option.type === void 0 ? "missing" : JSON.stringify(option.type)}. Use ${quoted(OPTION_TYPES, "disjunction")}.`);
387
394
  ok = checkName(ctx, option.name, `${label}.name`, true) && ok;
388
395
  ok = checkDescription(ctx, option.description, `${label}.description`) && ok;
389
- if (option.required !== void 0 && typeof option.required !== "boolean") ok = fail$1(ctx, "invalid-option", `${label}.required must be a boolean.`);
396
+ if (option.required !== void 0 && typeof option.required !== "boolean") ok = fail$1(ctx, "invalid-option", `${label}.required is ${typeOf(option.required)}. Use true or false.`);
390
397
  ok = checkLocalizations(ctx, option.nameLocalizations, `${label}.nameLocalizations`) && ok;
391
398
  ok = checkLocalizations(ctx, option.descriptionLocalizations, `${label}.descriptionLocalizations`) && ok;
392
399
  const type = option.type;
393
- const numeric = type === "integer" || type === "number";
394
- const choosable = type === "string" || numeric;
395
- for (const key of [
396
- "choices",
397
- "autocomplete",
398
- "minLength",
399
- "maxLength",
400
- "minValue",
401
- "maxValue",
402
- "channelTypes"
403
- ]) {
404
- if (option[key] === void 0) continue;
405
- if (!((key === "choices" || key === "autocomplete") && choosable ? true : (key === "minLength" || key === "maxLength") && type === "string" ? true : (key === "minValue" || key === "maxValue") && numeric ? true : key === "channelTypes" && type === "channel")) ok = fail$1(ctx, "invalid-option", `${label}.${key} is not valid for a "${type}" option.`);
400
+ for (const [key, types] of Object.entries(FIELD_TYPES)) {
401
+ if (option[key] === void 0 || types.includes(type)) continue;
402
+ ok = fail$1(ctx, "invalid-option", `${label}.${key} only works on ${quoted(types, "conjunction")} options, and this one is "${type}".`);
406
403
  }
407
- if (option.autocomplete !== void 0 && typeof option.autocomplete !== "boolean") ok = fail$1(ctx, "invalid-option", `${label}.autocomplete must be a boolean.`);
404
+ if (option.autocomplete !== void 0 && typeof option.autocomplete !== "boolean") ok = fail$1(ctx, "invalid-option", `${label}.autocomplete is ${typeOf(option.autocomplete)}. Use true or false.`);
408
405
  if (option.choices !== void 0) {
409
- if (option.autocomplete === true) ok = fail$1(ctx, "invalid-option", `${label} cannot have both choices and autocomplete.`);
406
+ if (option.autocomplete === true) ok = fail$1(ctx, "invalid-option", `${label} has both choices and autocomplete. Discord allows one or the other.`);
410
407
  ok = checkChoices(ctx, option.choices, `${label}.choices`, type === "string") && ok;
411
408
  }
412
409
  for (const key of [
@@ -416,30 +413,30 @@ function checkOption(ctx, option, label) {
416
413
  "maxValue"
417
414
  ]) {
418
415
  const v = option[key];
419
- if (v !== void 0 && typeof v !== "number") ok = fail$1(ctx, "invalid-option", `${label}.${key} must be a number.`);
416
+ if (v !== void 0 && typeof v !== "number") ok = fail$1(ctx, "invalid-option", `${label}.${key} is ${typeOf(v)}, not a number.`);
420
417
  }
421
- if (typeof option.minLength === "number" && (option.minLength < 0 || option.minLength > 6e3)) ok = fail$1(ctx, "invalid-option", `${label}.minLength must be between 0 and 6000.`);
422
- if (typeof option.maxLength === "number" && (option.maxLength < 1 || option.maxLength > 6e3)) ok = fail$1(ctx, "invalid-option", `${label}.maxLength must be between 1 and 6000.`);
418
+ if (typeof option.minLength === "number" && (option.minLength < 0 || option.minLength > 6e3)) ok = fail$1(ctx, "invalid-option", `${label}.minLength is ${option.minLength}. Discord allows 0 to 6000.`);
419
+ if (typeof option.maxLength === "number" && (option.maxLength < 1 || option.maxLength > 6e3)) ok = fail$1(ctx, "invalid-option", `${label}.maxLength is ${option.maxLength}. Discord allows 1 to 6000.`);
423
420
  if (option.channelTypes !== void 0) {
424
- if (!Array.isArray(option.channelTypes) || option.channelTypes.some((c) => typeof c !== "number")) ok = fail$1(ctx, "invalid-option", `${label}.channelTypes must be an array of ChannelType values.`);
421
+ if (!Array.isArray(option.channelTypes) || option.channelTypes.some((c) => typeof c !== "number")) ok = fail$1(ctx, "invalid-option", `${label}.channelTypes has to be an array of ChannelType values from discord.js.`);
425
422
  }
426
423
  return ok;
427
424
  }
428
425
  function checkChoices(ctx, choices, label, isString) {
429
- if (!Array.isArray(choices)) return fail$1(ctx, "invalid-option", `${label} must be an array.`);
430
- if (choices.length > 25) return fail$1(ctx, "invalid-option", `${label} can have at most 25 entries.`);
426
+ if (!Array.isArray(choices)) return fail$1(ctx, "invalid-option", `${label} is ${typeOf(choices)}, not an array.`);
427
+ if (choices.length > 25) return fail$1(ctx, "invalid-option", `${label} has ${choices.length} entries. Discord allows 25 per option.`);
431
428
  let ok = true;
432
429
  for (const [index, choice] of choices.entries()) {
433
430
  const at = `${label}[${index}]`;
434
431
  if (!isRecord(choice)) {
435
- ok = fail$1(ctx, "invalid-option", `${at} must be an object with name and value.`);
432
+ ok = fail$1(ctx, "invalid-option", `${at} is ${typeOf(choice)}. Each choice is an object like { name: "Red", value: "red" }.`);
436
433
  continue;
437
434
  }
438
435
  const typed = choice;
439
- if (typeof typed.name !== "string" || typed.name.length === 0 || typed.name.length > 100) ok = fail$1(ctx, "invalid-option", `${at}.name must be a string of 1 to 100 characters.`);
436
+ if (typeof typed.name !== "string" || typed.name.length === 0 || typed.name.length > 100) ok = fail$1(ctx, "invalid-option", `${at}.name is ${sizeOf(typed.name)}. Discord needs a name of 1 to 100 characters.`);
440
437
  if (isString) {
441
- if (typeof typed.value !== "string" || typed.value.length === 0 || typed.value.length > 100) ok = fail$1(ctx, "invalid-option", `${at}.value must be a string of 1 to 100 characters.`);
442
- } else if (typeof typed.value !== "number") ok = fail$1(ctx, "invalid-option", `${at}.value must be a number.`);
438
+ if (typeof typed.value !== "string" || typed.value.length === 0 || typed.value.length > 100) ok = fail$1(ctx, "invalid-option", `${at}.value is ${sizeOf(typed.value)}. A string option's choices need a value of 1 to 100 characters.`);
439
+ } else if (typeof typed.value !== "number") ok = fail$1(ctx, "invalid-option", `${at}.value is ${typeOf(typed.value)}. A numeric option's choices need a number value.`);
443
440
  ok = checkLocalizations(ctx, typed.nameLocalizations, `${at}.nameLocalizations`) && ok;
444
441
  }
445
442
  return ok;
@@ -462,6 +459,17 @@ const COMMAND_TYPE = {
462
459
  user: ApplicationCommandType.User,
463
460
  message: ApplicationCommandType.Message
464
461
  };
462
+ const TYPE_LABEL = {
463
+ [ApplicationCommandType.ChatInput]: "slash command",
464
+ [ApplicationCommandType.User]: "user context menu command",
465
+ [ApplicationCommandType.Message]: "message context menu command"
466
+ };
467
+ /** How many commands of each type Discord takes, globally and in each server. */
468
+ const COMMAND_LIMITS = {
469
+ [ApplicationCommandType.ChatInput]: 100,
470
+ [ApplicationCommandType.User]: 15,
471
+ [ApplicationCommandType.Message]: 15
472
+ };
465
473
  /** Compiles the command routes of a route table into Discord command definitions. */
466
474
  async function compileCommands(table) {
467
475
  const diagnostics = new Diagnostics();
@@ -476,20 +484,37 @@ async function compileCommands(table) {
476
484
  const command = compileTopLevel(top, entries, routeMetas, diagnostics);
477
485
  if (command !== null) commands.push(command);
478
486
  }
479
- for (const entry of routeMetas.values()) if (!entry.used) diagnostics.warn("unused-route-meta", "This route.ts does not describe a command with subcommands and has no effect.", { file: entry.boundary.file });
487
+ for (const entry of routeMetas.values()) if (!entry.used) diagnostics.warn("unused-route-meta", "This route.ts has no effect because there are no subcommands below it. A plain command's meta goes in its command.ts.", { file: entry.boundary.file });
480
488
  detectDuplicateNames(commands, diagnostics);
489
+ checkLimits(commands, diagnostics);
481
490
  commands.sort((a, b) => a.type - b.type || a.name.localeCompare(b.name));
482
491
  return {
483
492
  commands,
484
493
  diagnostics
485
494
  };
486
495
  }
496
+ function checkLimits(commands, diagnostics) {
497
+ for (const [type, limit] of Object.entries(COMMAND_LIMITS)) {
498
+ const ofType = commands.filter((c) => c.type === Number(type));
499
+ const first = ofType[0];
500
+ if (first === void 0 || ofType.length <= limit) continue;
501
+ const slash = Number(type) === ApplicationCommandType.ChatInput;
502
+ diagnostics.error("too-many-commands", `The app has ${ofType.length} ${TYPE_LABEL[Number(type)]}s, and Discord allows ${limit}, globally and in each server. ${slash ? "Subcommands don't count toward it, so group related commands under one." : "Remove some."}`, { file: commandsDir(first) });
503
+ }
504
+ }
505
+ /** The `commands/` directory, found by walking up from one of the command's handlers. */
506
+ function commandsDir(command) {
507
+ const route = Object.values(command.handlers)[0];
508
+ let dir = path.dirname(route.file);
509
+ for (let i = 0; i < route.segments.length; i++) dir = path.dirname(dir);
510
+ return dir;
511
+ }
487
512
  async function loadRoutes(routes, diagnostics) {
488
513
  const commandRoutes = routes.filter((r) => r.kind === "command");
489
514
  return (await Promise.all(commandRoutes.map(async (route) => {
490
515
  const module = await importOrReport(route.file, diagnostics);
491
516
  if (module === null) return null;
492
- if (!checkDeclaredRoute(module, route, diagnostics)) return null;
517
+ if (!checkHandler(module, route, diagnostics)) return null;
493
518
  const meta = validateCommandMeta(module.meta, route.file, diagnostics);
494
519
  if (meta === null) return null;
495
520
  return {
@@ -505,7 +530,7 @@ async function loadRouteMetas(boundaries, diagnostics) {
505
530
  await Promise.all(relevant.map(async (boundary) => {
506
531
  const key = boundary.segments.filter((s) => s.type !== "group").map(formatSegment).join("/");
507
532
  if (key === "") {
508
- diagnostics.error("route-meta-without-path", "route.ts must live inside a command directory. At the commands root it describes nothing.", { file: boundary.file });
533
+ diagnostics.error("route-meta-without-path", "This route.ts isn't inside a command's directory, so it doesn't describe a command. Move it into the command's directory, like commands/moderation/route.ts.", { file: boundary.file });
509
534
  return;
510
535
  }
511
536
  const module = await importOrReport(boundary.file, diagnostics);
@@ -524,15 +549,16 @@ async function importOrReport(file, diagnostics) {
524
549
  try {
525
550
  return await loadModule(file);
526
551
  } catch (error) {
527
- diagnostics.error("module-load-failed", `Could not import this file: ${error instanceof Error ? error.message : String(error)}`, { file });
552
+ diagnostics.error("module-load-failed", `The compiler imports every route file to read its exports, and this one threw: ${error instanceof Error ? error.message : String(error)}`, { file });
528
553
  return null;
529
554
  }
530
555
  }
531
556
  function compileTopLevel(top, entries, routeMetas, diagnostics) {
532
557
  const direct = entries.filter((e) => e.parts.length === 1);
533
558
  const nested = entries.filter((e) => e.parts.length > 1);
559
+ if (direct.length > 1) return null;
534
560
  if (direct.length > 0 && nested.length > 0) {
535
- for (const entry of nested) diagnostics.error("mixed-command-and-subcommands", `"${top}" has its own command.ts and also subcommands. Discord does not allow both. Either remove ${relative$1(direct[0]?.route.file)} or move this handler out of ${top}/.`, {
561
+ for (const entry of nested) diagnostics.error("mixed-command-and-subcommands", `"${top}" has a command.ts and also subcommands, like this one. Discord doesn't let a command with subcommands run by itself. Replace ${relative$2(direct[0]?.route.file)} with a route.ts, or move this file out of ${top}/.`, {
536
562
  file: entry.route.file,
537
563
  route: entry.route.id
538
564
  });
@@ -540,7 +566,7 @@ function compileTopLevel(top, entries, routeMetas, diagnostics) {
540
566
  }
541
567
  const tooDeep = nested.filter((e) => e.parts.length > 3);
542
568
  if (tooDeep.length > 0) {
543
- for (const entry of tooDeep) diagnostics.error("command-too-deep", `Commands can nest at most three levels (command / group / subcommand). "${entry.route.path}" has ${entry.parts.length}.`, {
569
+ for (const entry of tooDeep) diagnostics.error("command-too-deep", `"${entry.route.path}" is ${entry.parts.length} levels deep. Discord commands go three levels at most: command, subcommand group, and subcommand. Remove a level of directories.`, {
544
570
  file: entry.route.file,
545
571
  route: entry.route.id
546
572
  });
@@ -567,14 +593,20 @@ function compilePlainCommand(entry, diagnostics) {
567
593
  payload.description = meta.description;
568
594
  if (meta.options !== void 0) payload.options = meta.options.map(optionPayload);
569
595
  }
596
+ const mode = deferMode(meta);
570
597
  return {
571
598
  name,
572
599
  type,
573
600
  handlers: { "": route },
601
+ defer: mode === null ? {} : { "": mode },
574
602
  payload: compact(payload),
575
603
  files: [route.file]
576
604
  };
577
605
  }
606
+ function deferMode(meta) {
607
+ if (meta.defer === "ephemeral") return "ephemeral";
608
+ return meta.defer === true ? "reply" : null;
609
+ }
578
610
  function compileParentCommand(top, nested, routeMetas, diagnostics) {
579
611
  const parent = requireRouteMeta(top, nested[0], routeMetas, diagnostics);
580
612
  if (parent === null) return null;
@@ -584,23 +616,30 @@ function compileParentCommand(top, nested, routeMetas, diagnostics) {
584
616
  return null;
585
617
  }
586
618
  const handlers = {};
619
+ const defer = {};
587
620
  const files = [parent.boundary.file];
588
621
  const options = [];
589
622
  let ok = true;
623
+ const place = (key, entry) => {
624
+ handlers[key] = entry.route;
625
+ files.push(entry.route.file);
626
+ const mode = deferMode(entry.meta);
627
+ if (mode !== null) defer[key] = mode;
628
+ };
590
629
  const bySecond = /* @__PURE__ */ new Map();
591
630
  for (const entry of nested) {
592
631
  const second = entry.parts[1];
593
632
  bySecond.set(second, [...bySecond.get(second) ?? [], entry]);
594
633
  }
595
634
  if (bySecond.size > 25) {
596
- diagnostics.error("too-many-subcommands", `"${top}" has ${bySecond.size} subcommands and groups. Discord allows at most 25.`, { file: parent.boundary.file });
635
+ diagnostics.error("too-many-subcommands", `"${top}" has ${bySecond.size} subcommands and groups. Discord allows 25 per command. Move some into a subcommand group or another command.`, { file: parent.boundary.file });
597
636
  ok = false;
598
637
  }
599
638
  for (const [second, entries] of [...bySecond].sort(([a], [b]) => a.localeCompare(b))) {
600
639
  const subs = entries.filter((e) => e.parts.length === 2);
601
640
  const grouped = entries.filter((e) => e.parts.length === 3);
602
641
  if (subs.length > 0 && grouped.length > 0) {
603
- for (const entry of grouped) diagnostics.error("mixed-subcommand-and-group", `"${top}/${second}" is both a subcommand (${relative$1(subs[0]?.route.file)}) and a subcommand group. Discord does not allow both.`, {
642
+ for (const entry of grouped) diagnostics.error("mixed-subcommand-and-group", `${relative$2(subs[0]?.route.file)} makes "${top} ${second}" a subcommand, and this file makes it a subcommand group. Discord doesn't allow both. Move that command.ts into its own directory under ${second}/, or move this file out.`, {
604
643
  file: entry.route.file,
605
644
  route: entry.route.id
606
645
  });
@@ -613,8 +652,7 @@ function compileParentCommand(top, nested, routeMetas, diagnostics) {
613
652
  ok = false;
614
653
  continue;
615
654
  }
616
- handlers[sub.name] = sub.route;
617
- files.push(sub.route.file);
655
+ place(sub.name, subs[0]);
618
656
  options.push(sub.payload);
619
657
  continue;
620
658
  }
@@ -632,12 +670,12 @@ function compileParentCommand(top, nested, routeMetas, diagnostics) {
632
670
  }
633
671
  const extra = topLevelKeysUsed(group.meta);
634
672
  if (extra.length > 0) {
635
- diagnostics.error("top-level-field-on-group", `${extra.map((k) => `meta.${k}`).join(", ")} only applies to top-level commands. Move it to ${relative$1(parent.boundary.file)}.`, { file: group.boundary.file });
673
+ diagnostics.error("top-level-field-on-group", `${fieldList(extra)} can't be set on a subcommand group. Discord applies ${extra.length === 1 ? "it" : "them"} to the whole command, so move ${extra.length === 1 ? "it" : "them"} to ${relative$2(parent.boundary.file)}.`, { file: group.boundary.file });
636
674
  ok = false;
637
675
  continue;
638
676
  }
639
677
  if (grouped.length > 25) {
640
- diagnostics.error("too-many-subcommands", `Group "${groupKey}" has ${grouped.length} subcommands. Discord allows at most 25.`, { file: group.boundary.file });
678
+ diagnostics.error("too-many-subcommands", `The "${groupKey}" group has ${grouped.length} subcommands. Discord allows 25 per group. Move some into another group.`, { file: group.boundary.file });
641
679
  ok = false;
642
680
  continue;
643
681
  }
@@ -649,8 +687,7 @@ function compileParentCommand(top, nested, routeMetas, diagnostics) {
649
687
  ok = false;
650
688
  continue;
651
689
  }
652
- handlers[`${groupName}/${sub.name}`] = sub.route;
653
- files.push(sub.route.file);
690
+ place(`${groupName}/${sub.name}`, entry);
654
691
  groupOptions.push(sub.payload);
655
692
  }
656
693
  options.push(compact({
@@ -674,6 +711,7 @@ function compileParentCommand(top, nested, routeMetas, diagnostics) {
674
711
  name,
675
712
  type: ApplicationCommandType.ChatInput,
676
713
  handlers,
714
+ defer,
677
715
  payload,
678
716
  files
679
717
  };
@@ -681,7 +719,7 @@ function compileParentCommand(top, nested, routeMetas, diagnostics) {
681
719
  function compileSubcommand(entry, diagnostics) {
682
720
  const { route, meta } = entry;
683
721
  if (meta.type !== void 0 && meta.type !== "chatInput") {
684
- diagnostics.error("context-menu-nested", `Context menu commands cannot be subcommands. Move ${relative$1(route.file)} to the top of commands/.`, {
722
+ diagnostics.error("context-menu-nested", `This is a context menu command, but it's nested under ${entry.parts[0]}/ as a subcommand. Discord doesn't allow context menu subcommands. Move it to its own directory directly under commands/, like commands/${entry.parts.at(-1)}/command.ts.`, {
685
723
  file: route.file,
686
724
  route: route.id
687
725
  });
@@ -689,7 +727,7 @@ function compileSubcommand(entry, diagnostics) {
689
727
  }
690
728
  const extra = topLevelKeysUsed(meta);
691
729
  if (extra.length > 0) {
692
- diagnostics.error("top-level-field-on-subcommand", `${extra.map((k) => `meta.${k}`).join(", ")} only applies to top-level commands. Move it to the route.ts of "${entry.parts[0]}".`, {
730
+ diagnostics.error("top-level-field-on-subcommand", `${fieldList(extra)} can't be set on a subcommand. Discord applies ${extra.length === 1 ? "it" : "them"} to the whole command, so move ${extra.length === 1 ? "it" : "them"} to the route.ts in ${entry.parts[0]}/.`, {
693
731
  file: route.file,
694
732
  route: route.id
695
733
  });
@@ -719,7 +757,7 @@ function requireRouteMeta(key, child, routeMetas, diagnostics) {
719
757
  return entry;
720
758
  }
721
759
  const dir = directoryForPath(child.route, key.split("/").length);
722
- diagnostics.error("missing-route-meta", `"${key}" has subcommands but no route.ts. Discord needs a description for it. Add ${path.join(dir, "route.ts")} exporting \`meta\` with a description.`, {
760
+ diagnostics.error("missing-route-meta", `"${key}" has subcommands but no route.ts. Discord needs a description for it, and without a command.ts that goes in route.ts. Add ${relative$2(path.join(dir, "route.ts"))} with export const meta = { description: "..." }.`, {
723
761
  file: child.route.file,
724
762
  route: child.route.id
725
763
  });
@@ -746,11 +784,15 @@ function isValidChatInputName(name) {
746
784
  return /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u.test(name) && name === name.toLowerCase();
747
785
  }
748
786
  function reportDirectoryName(route, name, diagnostics, file) {
749
- diagnostics.error("invalid-name", `"${name}" is not a valid chat input command name. Discord requires lowercase letters, digits, hyphens, and underscores, 1 to 32 characters. Rename the directory or set \`meta.name\`.`, {
787
+ diagnostics.error("invalid-name", `"${name}" isn't a valid slash command name. Discord only allows lowercase letters, digits, hyphens, and underscores, up to 32 characters. Rename the directory, or set meta.name.`, {
750
788
  file: file ?? route.file,
751
789
  route: route.id
752
790
  });
753
791
  }
792
+ /** `meta.nsfw and meta.contexts` */
793
+ function fieldList(keys) {
794
+ return new Intl.ListFormat("en").format(keys.map((key) => `meta.${key}`));
795
+ }
754
796
  function detectDuplicateNames(commands, diagnostics) {
755
797
  const seen = /* @__PURE__ */ new Map();
756
798
  for (const command of commands) {
@@ -760,7 +802,7 @@ function detectDuplicateNames(commands, diagnostics) {
760
802
  seen.set(key, command);
761
803
  continue;
762
804
  }
763
- diagnostics.error("duplicate-command-name", `Two commands register as "${command.name}": ${relative$1(existing.files[0])} and ${relative$1(command.files[0])}. Check \`meta.name\` overrides.`, { file: command.files[0] });
805
+ diagnostics.error("duplicate-command-name", `${relative$2(existing.files[0])} and ${relative$2(command.files[0])} both register a ${TYPE_LABEL[command.type]} named "${command.name}". Discord needs the names to be unique, so change meta.name or the directory of one of them.`, { file: command.files[0] });
764
806
  }
765
807
  }
766
808
  function optionPayload(option) {
@@ -816,7 +858,7 @@ function compact(object) {
816
858
  for (const [key, value] of Object.entries(object)) if (value !== void 0) out[key] = value;
817
859
  return out;
818
860
  }
819
- function relative$1(file) {
861
+ function relative$2(file) {
820
862
  return file === void 0 ? "?" : path.relative(process.cwd(), file).split(path.sep).join("/");
821
863
  }
822
864
  //#endregion
@@ -840,7 +882,7 @@ async function compileEvents(table) {
840
882
  if (modes.size > 1) {
841
883
  for (const handler of handlers) {
842
884
  if (handler.mode === void 0) continue;
843
- diagnostics.error("event-mode-conflict", `Handlers of "${name}" disagree on \`meta.mode\`: ${[...modes].map((m) => `"${m}"`).join(" and ")}. All handlers of one event share a mode; set it on one file or make them agree.`, {
885
+ diagnostics.error("event-mode-conflict", `Handlers of "${name}" set meta.mode to both "concurrent" and "sequential". The mode applies to all of an event's handlers, so set it in one file, or make them match.`, {
844
886
  file: handler.route.file,
845
887
  route: handler.route.id
846
888
  });
@@ -870,13 +912,13 @@ async function loadHandler(route, diagnostics) {
870
912
  try {
871
913
  module = await loadModule(route.file);
872
914
  } catch (error) {
873
- diagnostics.error("module-load-failed", `Could not import this file: ${error instanceof Error ? error.message : String(error)}`, {
915
+ diagnostics.error("module-load-failed", `The compiler imports every route file to read its exports, and this one threw: ${error instanceof Error ? error.message : String(error)}`, {
874
916
  file: route.file,
875
917
  route: route.id
876
918
  });
877
919
  return null;
878
920
  }
879
- if (!checkDeclaredRoute(module, route, diagnostics, name)) return null;
921
+ if (!checkHandler(module, route, diagnostics, name)) return null;
880
922
  const meta = validateEventMeta(module.meta, route, diagnostics);
881
923
  if (meta === null) return null;
882
924
  return {
@@ -892,7 +934,7 @@ function eventName(route, diagnostics) {
892
934
  const first = statics[0];
893
935
  if (first === void 0 || first.type !== "static") return null;
894
936
  if (statics.length > 1) {
895
- diagnostics.error("event-nested-path", `Event handlers live directly under events/<eventName>/. "${route.path}" adds ${statics.slice(1).map(formatSegment).join("/")} below the event name. Use a route group like (${statics[1]?.name}) to keep several handlers apart.`, {
937
+ diagnostics.error("event-nested-path", `"${route.path}" has ${statics.slice(1).map(formatSegment).join("/")}/ below the event name, and event handlers go directly in events/<eventName>/. To split an event across files, use route groups, like events/${first.name}/(${statics[1]?.name})/event.ts.`, {
896
938
  file: route.file,
897
939
  route: route.id
898
940
  });
@@ -901,8 +943,8 @@ function eventName(route, diagnostics) {
901
943
  const name = first.name;
902
944
  if (EVENT_NAMES.has(name)) return name;
903
945
  const renamed = RENAMED[name];
904
- const hint = renamed !== void 0 ? `discord.js renamed it to "${renamed}".` : closest(name) ? `Did you mean "${closest(name)}"?` : "Event names are the lowerCamelCase values of discord.js's `Events` enum.";
905
- diagnostics.error("unknown-event", `"${name}" is not a discord.js event. ${hint} Rename the directory.`, {
946
+ const hint = renamed !== void 0 ? `discord.js renamed it to "${renamed}".` : closest(name) ? `Did you mean "${closest(name)}"?` : "Event directories are named after a value of discord.js's Events enum, like messageCreate.";
947
+ diagnostics.error("unknown-event", `"${name}" isn't a discord.js event. ${hint} Rename the directory.`, {
906
948
  file: route.file,
907
949
  route: route.id
908
950
  });
@@ -917,11 +959,11 @@ function validateEventMeta(value, route, diagnostics) {
917
959
  });
918
960
  return null;
919
961
  };
920
- if (typeof value !== "object" || value === null || Array.isArray(value)) return fail("`meta` must be an object.");
962
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return fail("meta has to be an object, like { order: 1 }.");
921
963
  const meta = value;
922
- if (meta.once !== void 0 && typeof meta.once !== "boolean") return fail("`meta.once` must be a boolean.");
923
- if (meta.order !== void 0 && (typeof meta.order !== "number" || !Number.isFinite(meta.order))) return fail("`meta.order` must be a finite number.");
924
- if (meta.mode !== void 0 && meta.mode !== "sequential" && meta.mode !== "concurrent") return fail("`meta.mode` must be \"sequential\" or \"concurrent\".");
964
+ if (meta.once !== void 0 && typeof meta.once !== "boolean") return fail("meta.once has to be true or false.");
965
+ if (meta.order !== void 0 && (typeof meta.order !== "number" || !Number.isFinite(meta.order))) return fail(`meta.order is ${String(meta.order)}. Use a finite number.`);
966
+ if (meta.mode !== void 0 && meta.mode !== "sequential" && meta.mode !== "concurrent") return fail(`meta.mode is ${JSON.stringify(meta.mode)}. Use "sequential" or "concurrent".`);
925
967
  return meta;
926
968
  }
927
969
  /** Case-insensitive match against known events, to catch `GuildMemberAdd` or `guildmemberadd`. */
@@ -1040,6 +1082,12 @@ const BOUNDARY_KINDS = /* @__PURE__ */ new Set([
1040
1082
  "error",
1041
1083
  "route"
1042
1084
  ]);
1085
+ /** A directory name for the examples in messages. */
1086
+ const EXAMPLE_DIR = {
1087
+ command: "ping",
1088
+ component: "confirm",
1089
+ event: "messageCreate"
1090
+ };
1043
1091
  /** Discovers the app directory and builds the route table. */
1044
1092
  function buildRouteTable(appDir) {
1045
1093
  return buildRouteTableFromFiles(discover(appDir));
@@ -1057,12 +1105,13 @@ function buildRouteTableFromFiles(files) {
1057
1105
  segments: [],
1058
1106
  file: source.file
1059
1107
  });
1060
- else diagnostics.error("file-outside-category", `${path.basename(source.file)} must live under commands/, components/, or events/. Only middleware and error files may sit at the app root.`, { file: source.file });
1108
+ else diagnostics.error("file-outside-category", `${path.basename(source.file)} is directly in the app directory, where only middleware and error files go. Move it under commands/, components/, or events/.`, { file: source.file });
1061
1109
  continue;
1062
1110
  }
1063
1111
  const category = CATEGORY_DIRS[categoryDir];
1064
1112
  if (category === void 0) {
1065
- diagnostics.error("unknown-category", `"${categoryDir}/" is not a route area. Reserved files must live under commands/, components/, or events/.`, { file: source.file });
1113
+ const name = path.basename(source.file);
1114
+ diagnostics.error("unknown-category", `${name} is in ${categoryDir}/, which isn't a route directory. Nectar treats every file named ${name} as a route file, so move it under commands/, components/, or events/, or rename it.`, { file: source.file });
1066
1115
  continue;
1067
1116
  }
1068
1117
  const segments = parseSegments(rest, source, diagnostics);
@@ -1077,7 +1126,8 @@ function buildRouteTableFromFiles(files) {
1077
1126
  continue;
1078
1127
  }
1079
1128
  if (!HANDLER_KINDS[category].has(source.kind)) {
1080
- diagnostics.error("file-in-wrong-category", `${path.basename(source.file)} does not belong under ${categoryDir}/. Expected one of: ${[...HANDLER_KINDS[category]].map((k) => `${k}.ts`).join(", ")}.`, { file: source.file });
1129
+ const home = Object.entries(CATEGORY_DIRS).find(([, c]) => HANDLER_KINDS[c].has(source.kind));
1130
+ diagnostics.error("file-in-wrong-category", `${path.basename(source.file)} belongs under ${home?.[0]}/, not ${categoryDir}/.`, { file: source.file });
1081
1131
  continue;
1082
1132
  }
1083
1133
  const route = makeRoute(category, source.kind, segments, source.file, diagnostics);
@@ -1103,31 +1153,35 @@ function parseSegments(dirs, source, diagnostics) {
1103
1153
  return segments;
1104
1154
  }
1105
1155
  function makeRoute(category, kind, segments, file, diagnostics) {
1156
+ const name = path.basename(file);
1157
+ const dir = `${category}s`;
1106
1158
  if (segments.length === 0) {
1107
- diagnostics.error("route-without-path", `${path.basename(file)} needs a named directory. Files directly inside ${category}s/ have no route path.`, { file });
1159
+ diagnostics.error("route-without-path", `${name} is directly in ${dir}/, so it has no route path. Put it in a named directory, like ${dir}/${EXAMPLE_DIR[category]}/${name}.`, { file });
1160
+ return null;
1161
+ }
1162
+ if (segments.every((segment) => segment.type === "group")) {
1163
+ const groups = segments.map(formatSegment).join("/");
1164
+ const example = category === "event" ? `${dir}/${EXAMPLE_DIR[category]}/${groups}/${name}` : `${dir}/${groups}/${EXAMPLE_DIR[category]}/${name}`;
1165
+ diagnostics.error("route-without-path", `${name} is only inside route groups, and groups aren't part of the route path. Put it in a named directory, like ${example}.`, { file });
1108
1166
  return null;
1109
1167
  }
1110
1168
  const params = [];
1111
1169
  for (const [index, segment] of segments.entries()) if (segment.type === "dynamic" || segment.type === "catchAll") {
1112
1170
  if (category !== "component") {
1113
- diagnostics.error("dynamic-segment-not-allowed", `${formatSegment(segment)} is a dynamic segment, but ${category} routes cannot carry parameters. Only component routes can.`, { file });
1171
+ diagnostics.error("dynamic-segment-not-allowed", `${formatSegment(segment)} is a parameter, and only component routes can have parameters. ${category === "command" ? "Discord registers commands under fixed names, so take input with meta.options instead." : "The directory has to be named after a discord.js event, like events/messageCreate/."}`, { file });
1114
1172
  return null;
1115
1173
  }
1116
1174
  if (params.includes(segment.name)) {
1117
- diagnostics.error("duplicate-param", `Parameter "${segment.name}" appears twice in the same route.`, { file });
1175
+ diagnostics.error("duplicate-param", `The parameter "${segment.name}" appears twice in this route. Each parameter becomes a key of ctx.params, so the names have to differ. Rename one of the directories.`, { file });
1118
1176
  return null;
1119
1177
  }
1120
1178
  if (segment.type === "catchAll" && index !== segments.length - 1) {
1121
- diagnostics.error("catch-all-not-last", `${formatSegment(segment)} must be the last segment of the route.`, { file });
1179
+ diagnostics.error("catch-all-not-last", `${formatSegment(segment)} has more directories after it. A catch-all takes all the remaining values, so it has to be the last segment. Use [${segment.name}] if it only needs one value.`, { file });
1122
1180
  return null;
1123
1181
  }
1124
1182
  params.push(segment.name);
1125
1183
  }
1126
1184
  const routePath = segments.filter((segment) => segment.type !== "group" || category === "event").map(formatSegment).join("/");
1127
- if (routePath === "") {
1128
- diagnostics.error("route-without-path", `${path.basename(file)} sits only inside route groups. Groups do not contribute to the route, so this route has no path.`, { file });
1129
- return null;
1130
- }
1131
1185
  const id = `${category}:${routePath}`;
1132
1186
  return {
1133
1187
  id,
@@ -1153,12 +1207,16 @@ function detectDuplicates(routes, diagnostics) {
1153
1207
  seen.set(key, route);
1154
1208
  continue;
1155
1209
  }
1156
- diagnostics.error("duplicate-route", existing.kind === route.kind ? `Route ${route.id} is defined twice: ${existing.file} and ${route.file}. Route groups do not make paths distinct.` : `Route ${route.id} has two handlers: ${existing.file} and ${route.file}. A component route takes one button.ts, select.ts, or modal.ts. Move one into its own directory.`, {
1210
+ const other = relative$1(existing.file);
1211
+ diagnostics.error("duplicate-route", existing.kind !== route.kind ? `${other} handles the same route, ${route.id}. customId() and the generated types identify a component by its path alone, so each path takes one button.ts, select.ts, or modal.ts. Move one of them into its own directory.` : path.dirname(existing.file) === path.dirname(route.file) ? `${other} is in the same directory and handles the same route, ${route.id}. Keep one of them.` : `${other} is the same route, ${route.id}. Route groups aren't part of the path, so they don't tell the two apart. Rename one of the directories.`, {
1157
1212
  file: route.file,
1158
1213
  route: route.id
1159
1214
  });
1160
1215
  }
1161
1216
  }
1217
+ function relative$1(file) {
1218
+ return path.relative(process.cwd(), file).split(path.sep).join("/");
1219
+ }
1162
1220
  //#endregion
1163
1221
  //#region src/compiler/graph.ts
1164
1222
  /** Runs the whole compiler pipeline on an app directory. */
@@ -1272,15 +1330,17 @@ async function compileProject(project, io) {
1272
1330
  return graph;
1273
1331
  }
1274
1332
  /**
1275
- * One diagnostic as a headline and an indented message:
1333
+ * One diagnostic as a headline, an indented message, and the code's reference entry:
1276
1334
  *
1277
- * ✖ error invalid-name app/commands/Bad Name/command.ts
1278
- * Command names must be lowercase ...
1335
+ * ✖ error invalid-name app/commands/Ping/command.ts
1336
+ * "Ping" isn't a valid slash command name. ...
1337
+ * https://nectar-js.github.io/nectar/reference/diagnostics#invalid-name
1279
1338
  */
1280
1339
  function formatDiagnostic(diagnostic, root) {
1281
1340
  const mark = diagnostic.severity === "error" ? fail(c.red("error")) : warn(c.yellow("warning"));
1282
1341
  const where = diagnostic.file === void 0 ? "" : ` ${relative(root, diagnostic.file)}`;
1283
- return [`${mark} ${c.dim(diagnostic.code)}${where}`, ...indent([diagnostic.message])].join("\n");
1342
+ const docs = docsUrl(diagnostic.code);
1343
+ return [`${mark} ${c.dim(diagnostic.code)}${where}`, ...indent([diagnostic.message, ...docs === void 0 ? [] : [link(docs)]])].join("\n");
1284
1344
  }
1285
1345
  function relative(root, file) {
1286
1346
  return path.relative(root, file).split(path.sep).join("/");
@@ -2222,4 +2282,4 @@ function commandHelp(command) {
2222
2282
  //#endregion
2223
2283
  export { main as t };
2224
2284
 
2225
- //# sourceMappingURL=cli-Ce-ZUj6M.js.map
2285
+ //# sourceMappingURL=cli-Cv0mpiRy.js.map