@nectar-js/nectar 0.1.0 → 0.2.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-BCYwtuo-.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-BN3P3MDg.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-B_bjrg1W.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";
@@ -162,7 +162,8 @@ async function compileAutocomplete(table, commands) {
162
162
  const results = await Promise.all(routes.map(async (route) => {
163
163
  const target = targets.get(route.id);
164
164
  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}".`, {
165
+ if (table.routes.some((r) => r.id === route.id && r.kind === "command")) return null;
166
+ 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
167
  file: route.file,
167
168
  route: route.id
168
169
  });
@@ -172,7 +173,7 @@ async function compileAutocomplete(table, commands) {
172
173
  try {
173
174
  module = await loadModule(route.file);
174
175
  } catch (error) {
175
- diagnostics.error("module-load-failed", `Could not import this file: ${error instanceof Error ? error.message : String(error)}`, {
176
+ 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
177
  file: route.file,
177
178
  route: route.id
178
179
  });
@@ -182,7 +183,7 @@ async function compileAutocomplete(table, commands) {
182
183
  let ok = true;
183
184
  for (const name of exported) {
184
185
  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.`, {
186
+ 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
187
  file: route.file,
187
188
  route: route.id
188
189
  });
@@ -190,7 +191,7 @@ async function compileAutocomplete(table, commands) {
190
191
  continue;
191
192
  }
192
193
  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)}`, {
194
+ 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
195
  file: route.file,
195
196
  route: route.id
196
197
  });
@@ -199,7 +200,7 @@ async function compileAutocomplete(table, commands) {
199
200
  }
200
201
  for (const name of [...target.options].sort()) {
201
202
  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.`, {
203
+ diagnostics.error("autocomplete-missing-handler", `Option "${name}" has autocomplete: true, but this file doesn't export a function named "${name}" to answer it.`, {
203
204
  file: route.file,
204
205
  route: route.id
205
206
  });
@@ -213,7 +214,8 @@ async function compileAutocomplete(table, commands) {
213
214
  }));
214
215
  for (const [id, target] of targets) {
215
216
  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.`, {
217
+ const names = [...target.options].map((o) => `"${o}"`);
218
+ 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
219
  file: target.command.file,
218
220
  route: id
219
221
  });
@@ -230,9 +232,11 @@ function autocompleteOptions(command, key) {
230
232
  return new Set((options ?? []).filter((o) => o.autocomplete === true).map((o) => o.name));
231
233
  }
232
234
  function expected(options) {
233
- return options.size === 0 ? "The command declares no autocomplete options." : `Expected one of: ${[...options].sort().join(", ")}.`;
235
+ const names = [...options].sort().map((o) => `"${o}"`);
236
+ if (names.length === 0) return "That command has no autocomplete options.";
237
+ return names.length === 1 ? `Its autocomplete option is ${names[0]}.` : `Its autocomplete options are ${new Intl.ListFormat("en").format(names)}.`;
234
238
  }
235
- function relative$2(file) {
239
+ function relative$3(file) {
236
240
  return path.relative(process.cwd(), file).split(path.sep).join("/");
237
241
  }
238
242
  //#endregion
@@ -270,23 +274,23 @@ function validateCommandMeta(value, file, diagnostics) {
270
274
  file
271
275
  };
272
276
  if (value === void 0) {
273
- diagnostics.error("missing-meta", "command.ts must export a `meta` object with at least a description.", { file });
277
+ 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
278
  return null;
275
279
  }
276
280
  if (!isRecord(value)) {
277
- fail$1(ctx, "invalid-meta", "`meta` must be an object.");
281
+ fail$1(ctx, "invalid-meta", `meta is ${typeOf(value)}. Export an object, like { description: "..." }.`);
278
282
  return null;
279
283
  }
280
284
  let ok = true;
281
285
  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\".");
286
+ 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
287
  if (value.name !== void 0) ok = checkName(ctx, value.name, "meta.name", type === "chatInput") && ok;
284
288
  if (type === "chatInput") {
285
289
  ok = checkDescription(ctx, value.description, "meta.description") && ok;
286
290
  if (value.options !== void 0) ok = checkOptions(ctx, value.options) && ok;
287
291
  } 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.");
292
+ 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.");
293
+ if (value.options !== void 0) ok = fail$1(ctx, "invalid-meta", "Discord doesn't allow options on context menu commands. Remove meta.options.");
290
294
  }
291
295
  ok = checkLocalizations(ctx, value.nameLocalizations, "meta.nameLocalizations") && ok;
292
296
  ok = checkLocalizations(ctx, value.descriptionLocalizations, "meta.descriptionLocalizations") && ok;
@@ -300,11 +304,11 @@ function validateCommandRouteMeta(value, file, diagnostics) {
300
304
  file
301
305
  };
302
306
  if (value === void 0) {
303
- diagnostics.error("missing-meta", "route.ts must export a `meta` object with a description.", { file });
307
+ 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
308
  return null;
305
309
  }
306
310
  if (!isRecord(value)) {
307
- fail$1(ctx, "invalid-meta", "`meta` must be an object.");
311
+ fail$1(ctx, "invalid-meta", `meta is ${typeOf(value)}. Export an object, like { description: "..." }.`);
308
312
  return null;
309
313
  }
310
314
  let ok = checkDescription(ctx, value.description, "meta.description");
@@ -322,91 +326,105 @@ function fail$1(ctx, code, message) {
322
326
  ctx.diagnostics.error(code, message, { file: ctx.file });
323
327
  return false;
324
328
  }
329
+ /** What a text field holds, for length errors: `empty`, `140 characters`, or its type. */
330
+ function sizeOf(value) {
331
+ if (typeof value !== "string") return typeOf(value);
332
+ return value === "" ? "empty" : `${value.length} characters`;
333
+ }
325
334
  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.`);
335
+ 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
336
  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.`);
337
+ 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
338
  }
330
339
  return true;
331
340
  }
332
341
  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.`);
342
+ 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
343
  return true;
335
344
  }
336
345
  function checkLocalizations(ctx, value, label) {
337
346
  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.`);
347
+ if (!isRecord(value)) return fail$1(ctx, "invalid-meta", `${label} is ${typeOf(value)}. Use an object of locale to text, like { fr: "..." }.`);
348
+ 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
349
  return true;
341
350
  }
342
351
  function checkTopLevel(ctx, value) {
343
352
  let ok = true;
344
353
  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", [
354
+ 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.`);
355
+ 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.`);
356
+ ok = checkEnumArray(ctx, value.contexts, "meta.contexts", "InteractionContextType", [
348
357
  0,
349
358
  1,
350
359
  2
351
360
  ]) && ok;
352
- ok = checkEnumArray(ctx, value.integrationTypes, "meta.integrationTypes", [0, 1]) && ok;
361
+ ok = checkEnumArray(ctx, value.integrationTypes, "meta.integrationTypes", "ApplicationIntegrationType", [0, 1]) && ok;
353
362
  return ok;
354
363
  }
355
- function checkEnumArray(ctx, value, label, allowed) {
364
+ function checkEnumArray(ctx, value, label, enumName, allowed) {
356
365
  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.`);
366
+ 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
367
  return true;
359
368
  }
360
369
  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.");
370
+ if (!Array.isArray(options)) return fail$1(ctx, "invalid-option", `meta.options is ${typeOf(options)}, not an array.`);
371
+ if (options.length > 25) return fail$1(ctx, "invalid-option", `meta.options has ${options.length} options. Discord allows 25 per command.`);
363
372
  let ok = true;
364
373
  const names = /* @__PURE__ */ new Set();
365
374
  let seenOptional = false;
366
375
  for (const [index, option] of options.entries()) {
367
376
  const label = `meta.options[${index}]`;
368
377
  if (!isRecord(option)) {
369
- ok = fail$1(ctx, "invalid-option", `${label} must be an object.`);
378
+ ok = fail$1(ctx, "invalid-option", `${label} is ${typeOf(option)}, not an object.`);
370
379
  continue;
371
380
  }
372
381
  const typedOk = checkOption(ctx, option, label);
373
382
  ok = typedOk && ok;
374
383
  if (!typedOk) continue;
375
384
  const typed = option;
376
- if (names.has(typed.name)) ok = fail$1(ctx, "invalid-option", `${label}: option name "${typed.name}" is used twice.`);
385
+ 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
386
  names.add(typed.name);
378
387
  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.`);
388
+ 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
389
  } else seenOptional = true;
381
390
  }
382
391
  return ok;
383
392
  }
393
+ /** The option types each type-specific field works on. */
394
+ const FIELD_TYPES = {
395
+ choices: [
396
+ "string",
397
+ "integer",
398
+ "number"
399
+ ],
400
+ autocomplete: [
401
+ "string",
402
+ "integer",
403
+ "number"
404
+ ],
405
+ minLength: ["string"],
406
+ maxLength: ["string"],
407
+ minValue: ["integer", "number"],
408
+ maxValue: ["integer", "number"],
409
+ channelTypes: ["channel"]
410
+ };
411
+ const quoted = (items, type) => new Intl.ListFormat("en", { type }).format([...items].map((item) => `"${item}"`));
384
412
  function checkOption(ctx, option, label) {
385
413
  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(", ")}.`);
414
+ 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
415
  ok = checkName(ctx, option.name, `${label}.name`, true) && ok;
388
416
  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.`);
417
+ 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
418
  ok = checkLocalizations(ctx, option.nameLocalizations, `${label}.nameLocalizations`) && ok;
391
419
  ok = checkLocalizations(ctx, option.descriptionLocalizations, `${label}.descriptionLocalizations`) && ok;
392
420
  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.`);
421
+ for (const [key, types] of Object.entries(FIELD_TYPES)) {
422
+ if (option[key] === void 0 || types.includes(type)) continue;
423
+ ok = fail$1(ctx, "invalid-option", `${label}.${key} only works on ${quoted(types, "conjunction")} options, and this one is "${type}".`);
406
424
  }
407
- if (option.autocomplete !== void 0 && typeof option.autocomplete !== "boolean") ok = fail$1(ctx, "invalid-option", `${label}.autocomplete must be a boolean.`);
425
+ 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
426
  if (option.choices !== void 0) {
409
- if (option.autocomplete === true) ok = fail$1(ctx, "invalid-option", `${label} cannot have both choices and autocomplete.`);
427
+ if (option.autocomplete === true) ok = fail$1(ctx, "invalid-option", `${label} has both choices and autocomplete. Discord allows one or the other.`);
410
428
  ok = checkChoices(ctx, option.choices, `${label}.choices`, type === "string") && ok;
411
429
  }
412
430
  for (const key of [
@@ -416,30 +434,30 @@ function checkOption(ctx, option, label) {
416
434
  "maxValue"
417
435
  ]) {
418
436
  const v = option[key];
419
- if (v !== void 0 && typeof v !== "number") ok = fail$1(ctx, "invalid-option", `${label}.${key} must be a number.`);
437
+ if (v !== void 0 && typeof v !== "number") ok = fail$1(ctx, "invalid-option", `${label}.${key} is ${typeOf(v)}, not a number.`);
420
438
  }
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.`);
439
+ 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.`);
440
+ 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
441
  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.`);
442
+ 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
443
  }
426
444
  return ok;
427
445
  }
428
446
  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.`);
447
+ if (!Array.isArray(choices)) return fail$1(ctx, "invalid-option", `${label} is ${typeOf(choices)}, not an array.`);
448
+ if (choices.length > 25) return fail$1(ctx, "invalid-option", `${label} has ${choices.length} entries. Discord allows 25 per option.`);
431
449
  let ok = true;
432
450
  for (const [index, choice] of choices.entries()) {
433
451
  const at = `${label}[${index}]`;
434
452
  if (!isRecord(choice)) {
435
- ok = fail$1(ctx, "invalid-option", `${at} must be an object with name and value.`);
453
+ ok = fail$1(ctx, "invalid-option", `${at} is ${typeOf(choice)}. Each choice is an object like { name: "Red", value: "red" }.`);
436
454
  continue;
437
455
  }
438
456
  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.`);
457
+ 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
458
  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.`);
459
+ 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.`);
460
+ } 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
461
  ok = checkLocalizations(ctx, typed.nameLocalizations, `${at}.nameLocalizations`) && ok;
444
462
  }
445
463
  return ok;
@@ -462,6 +480,17 @@ const COMMAND_TYPE = {
462
480
  user: ApplicationCommandType.User,
463
481
  message: ApplicationCommandType.Message
464
482
  };
483
+ const TYPE_LABEL = {
484
+ [ApplicationCommandType.ChatInput]: "slash command",
485
+ [ApplicationCommandType.User]: "user context menu command",
486
+ [ApplicationCommandType.Message]: "message context menu command"
487
+ };
488
+ /** How many commands of each type Discord takes, globally and in each server. */
489
+ const COMMAND_LIMITS = {
490
+ [ApplicationCommandType.ChatInput]: 100,
491
+ [ApplicationCommandType.User]: 15,
492
+ [ApplicationCommandType.Message]: 15
493
+ };
465
494
  /** Compiles the command routes of a route table into Discord command definitions. */
466
495
  async function compileCommands(table) {
467
496
  const diagnostics = new Diagnostics();
@@ -476,20 +505,37 @@ async function compileCommands(table) {
476
505
  const command = compileTopLevel(top, entries, routeMetas, diagnostics);
477
506
  if (command !== null) commands.push(command);
478
507
  }
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 });
508
+ 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
509
  detectDuplicateNames(commands, diagnostics);
510
+ checkLimits(commands, diagnostics);
481
511
  commands.sort((a, b) => a.type - b.type || a.name.localeCompare(b.name));
482
512
  return {
483
513
  commands,
484
514
  diagnostics
485
515
  };
486
516
  }
517
+ function checkLimits(commands, diagnostics) {
518
+ for (const [type, limit] of Object.entries(COMMAND_LIMITS)) {
519
+ const ofType = commands.filter((c) => c.type === Number(type));
520
+ const first = ofType[0];
521
+ if (first === void 0 || ofType.length <= limit) continue;
522
+ const slash = Number(type) === ApplicationCommandType.ChatInput;
523
+ 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) });
524
+ }
525
+ }
526
+ /** The `commands/` directory, found by walking up from one of the command's handlers. */
527
+ function commandsDir(command) {
528
+ const route = Object.values(command.handlers)[0];
529
+ let dir = path.dirname(route.file);
530
+ for (let i = 0; i < route.segments.length; i++) dir = path.dirname(dir);
531
+ return dir;
532
+ }
487
533
  async function loadRoutes(routes, diagnostics) {
488
534
  const commandRoutes = routes.filter((r) => r.kind === "command");
489
535
  return (await Promise.all(commandRoutes.map(async (route) => {
490
536
  const module = await importOrReport(route.file, diagnostics);
491
537
  if (module === null) return null;
492
- if (!checkDeclaredRoute(module, route, diagnostics)) return null;
538
+ if (!checkHandler(module, route, diagnostics)) return null;
493
539
  const meta = validateCommandMeta(module.meta, route.file, diagnostics);
494
540
  if (meta === null) return null;
495
541
  return {
@@ -505,7 +551,7 @@ async function loadRouteMetas(boundaries, diagnostics) {
505
551
  await Promise.all(relevant.map(async (boundary) => {
506
552
  const key = boundary.segments.filter((s) => s.type !== "group").map(formatSegment).join("/");
507
553
  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 });
554
+ 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
555
  return;
510
556
  }
511
557
  const module = await importOrReport(boundary.file, diagnostics);
@@ -524,15 +570,16 @@ async function importOrReport(file, diagnostics) {
524
570
  try {
525
571
  return await loadModule(file);
526
572
  } catch (error) {
527
- diagnostics.error("module-load-failed", `Could not import this file: ${error instanceof Error ? error.message : String(error)}`, { file });
573
+ 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
574
  return null;
529
575
  }
530
576
  }
531
577
  function compileTopLevel(top, entries, routeMetas, diagnostics) {
532
578
  const direct = entries.filter((e) => e.parts.length === 1);
533
579
  const nested = entries.filter((e) => e.parts.length > 1);
580
+ if (direct.length > 1) return null;
534
581
  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}/.`, {
582
+ 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
583
  file: entry.route.file,
537
584
  route: entry.route.id
538
585
  });
@@ -540,7 +587,7 @@ function compileTopLevel(top, entries, routeMetas, diagnostics) {
540
587
  }
541
588
  const tooDeep = nested.filter((e) => e.parts.length > 3);
542
589
  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}.`, {
590
+ 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
591
  file: entry.route.file,
545
592
  route: entry.route.id
546
593
  });
@@ -593,14 +640,14 @@ function compileParentCommand(top, nested, routeMetas, diagnostics) {
593
640
  bySecond.set(second, [...bySecond.get(second) ?? [], entry]);
594
641
  }
595
642
  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 });
643
+ 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
644
  ok = false;
598
645
  }
599
646
  for (const [second, entries] of [...bySecond].sort(([a], [b]) => a.localeCompare(b))) {
600
647
  const subs = entries.filter((e) => e.parts.length === 2);
601
648
  const grouped = entries.filter((e) => e.parts.length === 3);
602
649
  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.`, {
650
+ 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
651
  file: entry.route.file,
605
652
  route: entry.route.id
606
653
  });
@@ -632,12 +679,12 @@ function compileParentCommand(top, nested, routeMetas, diagnostics) {
632
679
  }
633
680
  const extra = topLevelKeysUsed(group.meta);
634
681
  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 });
682
+ 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
683
  ok = false;
637
684
  continue;
638
685
  }
639
686
  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 });
687
+ 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
688
  ok = false;
642
689
  continue;
643
690
  }
@@ -681,7 +728,7 @@ function compileParentCommand(top, nested, routeMetas, diagnostics) {
681
728
  function compileSubcommand(entry, diagnostics) {
682
729
  const { route, meta } = entry;
683
730
  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/.`, {
731
+ 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
732
  file: route.file,
686
733
  route: route.id
687
734
  });
@@ -689,7 +736,7 @@ function compileSubcommand(entry, diagnostics) {
689
736
  }
690
737
  const extra = topLevelKeysUsed(meta);
691
738
  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]}".`, {
739
+ 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
740
  file: route.file,
694
741
  route: route.id
695
742
  });
@@ -719,7 +766,7 @@ function requireRouteMeta(key, child, routeMetas, diagnostics) {
719
766
  return entry;
720
767
  }
721
768
  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.`, {
769
+ 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
770
  file: child.route.file,
724
771
  route: child.route.id
725
772
  });
@@ -746,11 +793,15 @@ function isValidChatInputName(name) {
746
793
  return /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u.test(name) && name === name.toLowerCase();
747
794
  }
748
795
  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\`.`, {
796
+ 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
797
  file: file ?? route.file,
751
798
  route: route.id
752
799
  });
753
800
  }
801
+ /** `meta.nsfw and meta.contexts` */
802
+ function fieldList(keys) {
803
+ return new Intl.ListFormat("en").format(keys.map((key) => `meta.${key}`));
804
+ }
754
805
  function detectDuplicateNames(commands, diagnostics) {
755
806
  const seen = /* @__PURE__ */ new Map();
756
807
  for (const command of commands) {
@@ -760,7 +811,7 @@ function detectDuplicateNames(commands, diagnostics) {
760
811
  seen.set(key, command);
761
812
  continue;
762
813
  }
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] });
814
+ 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
815
  }
765
816
  }
766
817
  function optionPayload(option) {
@@ -816,7 +867,7 @@ function compact(object) {
816
867
  for (const [key, value] of Object.entries(object)) if (value !== void 0) out[key] = value;
817
868
  return out;
818
869
  }
819
- function relative$1(file) {
870
+ function relative$2(file) {
820
871
  return file === void 0 ? "?" : path.relative(process.cwd(), file).split(path.sep).join("/");
821
872
  }
822
873
  //#endregion
@@ -840,7 +891,7 @@ async function compileEvents(table) {
840
891
  if (modes.size > 1) {
841
892
  for (const handler of handlers) {
842
893
  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.`, {
894
+ 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
895
  file: handler.route.file,
845
896
  route: handler.route.id
846
897
  });
@@ -870,13 +921,13 @@ async function loadHandler(route, diagnostics) {
870
921
  try {
871
922
  module = await loadModule(route.file);
872
923
  } catch (error) {
873
- diagnostics.error("module-load-failed", `Could not import this file: ${error instanceof Error ? error.message : String(error)}`, {
924
+ 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
925
  file: route.file,
875
926
  route: route.id
876
927
  });
877
928
  return null;
878
929
  }
879
- if (!checkDeclaredRoute(module, route, diagnostics, name)) return null;
930
+ if (!checkHandler(module, route, diagnostics, name)) return null;
880
931
  const meta = validateEventMeta(module.meta, route, diagnostics);
881
932
  if (meta === null) return null;
882
933
  return {
@@ -892,7 +943,7 @@ function eventName(route, diagnostics) {
892
943
  const first = statics[0];
893
944
  if (first === void 0 || first.type !== "static") return null;
894
945
  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.`, {
946
+ 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
947
  file: route.file,
897
948
  route: route.id
898
949
  });
@@ -901,8 +952,8 @@ function eventName(route, diagnostics) {
901
952
  const name = first.name;
902
953
  if (EVENT_NAMES.has(name)) return name;
903
954
  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.`, {
955
+ 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.";
956
+ diagnostics.error("unknown-event", `"${name}" isn't a discord.js event. ${hint} Rename the directory.`, {
906
957
  file: route.file,
907
958
  route: route.id
908
959
  });
@@ -917,11 +968,11 @@ function validateEventMeta(value, route, diagnostics) {
917
968
  });
918
969
  return null;
919
970
  };
920
- if (typeof value !== "object" || value === null || Array.isArray(value)) return fail("`meta` must be an object.");
971
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return fail("meta has to be an object, like { order: 1 }.");
921
972
  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\".");
973
+ if (meta.once !== void 0 && typeof meta.once !== "boolean") return fail("meta.once has to be true or false.");
974
+ 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.`);
975
+ 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
976
  return meta;
926
977
  }
927
978
  /** Case-insensitive match against known events, to catch `GuildMemberAdd` or `guildmemberadd`. */
@@ -1040,6 +1091,12 @@ const BOUNDARY_KINDS = /* @__PURE__ */ new Set([
1040
1091
  "error",
1041
1092
  "route"
1042
1093
  ]);
1094
+ /** A directory name for the examples in messages. */
1095
+ const EXAMPLE_DIR = {
1096
+ command: "ping",
1097
+ component: "confirm",
1098
+ event: "messageCreate"
1099
+ };
1043
1100
  /** Discovers the app directory and builds the route table. */
1044
1101
  function buildRouteTable(appDir) {
1045
1102
  return buildRouteTableFromFiles(discover(appDir));
@@ -1057,12 +1114,13 @@ function buildRouteTableFromFiles(files) {
1057
1114
  segments: [],
1058
1115
  file: source.file
1059
1116
  });
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 });
1117
+ 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
1118
  continue;
1062
1119
  }
1063
1120
  const category = CATEGORY_DIRS[categoryDir];
1064
1121
  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 });
1122
+ const name = path.basename(source.file);
1123
+ 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
1124
  continue;
1067
1125
  }
1068
1126
  const segments = parseSegments(rest, source, diagnostics);
@@ -1077,7 +1135,8 @@ function buildRouteTableFromFiles(files) {
1077
1135
  continue;
1078
1136
  }
1079
1137
  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 });
1138
+ const home = Object.entries(CATEGORY_DIRS).find(([, c]) => HANDLER_KINDS[c].has(source.kind));
1139
+ diagnostics.error("file-in-wrong-category", `${path.basename(source.file)} belongs under ${home?.[0]}/, not ${categoryDir}/.`, { file: source.file });
1081
1140
  continue;
1082
1141
  }
1083
1142
  const route = makeRoute(category, source.kind, segments, source.file, diagnostics);
@@ -1103,31 +1162,35 @@ function parseSegments(dirs, source, diagnostics) {
1103
1162
  return segments;
1104
1163
  }
1105
1164
  function makeRoute(category, kind, segments, file, diagnostics) {
1165
+ const name = path.basename(file);
1166
+ const dir = `${category}s`;
1106
1167
  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 });
1168
+ 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 });
1169
+ return null;
1170
+ }
1171
+ if (segments.every((segment) => segment.type === "group")) {
1172
+ const groups = segments.map(formatSegment).join("/");
1173
+ const example = category === "event" ? `${dir}/${EXAMPLE_DIR[category]}/${groups}/${name}` : `${dir}/${groups}/${EXAMPLE_DIR[category]}/${name}`;
1174
+ 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
1175
  return null;
1109
1176
  }
1110
1177
  const params = [];
1111
1178
  for (const [index, segment] of segments.entries()) if (segment.type === "dynamic" || segment.type === "catchAll") {
1112
1179
  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 });
1180
+ 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
1181
  return null;
1115
1182
  }
1116
1183
  if (params.includes(segment.name)) {
1117
- diagnostics.error("duplicate-param", `Parameter "${segment.name}" appears twice in the same route.`, { file });
1184
+ 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
1185
  return null;
1119
1186
  }
1120
1187
  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 });
1188
+ 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
1189
  return null;
1123
1190
  }
1124
1191
  params.push(segment.name);
1125
1192
  }
1126
1193
  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
1194
  const id = `${category}:${routePath}`;
1132
1195
  return {
1133
1196
  id,
@@ -1153,12 +1216,16 @@ function detectDuplicates(routes, diagnostics) {
1153
1216
  seen.set(key, route);
1154
1217
  continue;
1155
1218
  }
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.`, {
1219
+ const other = relative$1(existing.file);
1220
+ 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
1221
  file: route.file,
1158
1222
  route: route.id
1159
1223
  });
1160
1224
  }
1161
1225
  }
1226
+ function relative$1(file) {
1227
+ return path.relative(process.cwd(), file).split(path.sep).join("/");
1228
+ }
1162
1229
  //#endregion
1163
1230
  //#region src/compiler/graph.ts
1164
1231
  /** Runs the whole compiler pipeline on an app directory. */
@@ -1272,15 +1339,17 @@ async function compileProject(project, io) {
1272
1339
  return graph;
1273
1340
  }
1274
1341
  /**
1275
- * One diagnostic as a headline and an indented message:
1342
+ * One diagnostic as a headline, an indented message, and the code's reference entry:
1276
1343
  *
1277
- * ✖ error invalid-name app/commands/Bad Name/command.ts
1278
- * Command names must be lowercase ...
1344
+ * ✖ error invalid-name app/commands/Ping/command.ts
1345
+ * "Ping" isn't a valid slash command name. ...
1346
+ * https://nectar-js.github.io/nectar/reference/diagnostics#invalid-name
1279
1347
  */
1280
1348
  function formatDiagnostic(diagnostic, root) {
1281
1349
  const mark = diagnostic.severity === "error" ? fail(c.red("error")) : warn(c.yellow("warning"));
1282
1350
  const where = diagnostic.file === void 0 ? "" : ` ${relative(root, diagnostic.file)}`;
1283
- return [`${mark} ${c.dim(diagnostic.code)}${where}`, ...indent([diagnostic.message])].join("\n");
1351
+ const docs = docsUrl(diagnostic.code);
1352
+ return [`${mark} ${c.dim(diagnostic.code)}${where}`, ...indent([diagnostic.message, ...docs === void 0 ? [] : [link(docs)]])].join("\n");
1284
1353
  }
1285
1354
  function relative(root, file) {
1286
1355
  return path.relative(root, file).split(path.sep).join("/");
@@ -2222,4 +2291,4 @@ function commandHelp(command) {
2222
2291
  //#endregion
2223
2292
  export { main as t };
2224
2293
 
2225
- //# sourceMappingURL=cli-Ce-ZUj6M.js.map
2294
+ //# sourceMappingURL=cli-DJg1-t94.js.map