@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.
- package/dist/{cli-Ce-ZUj6M.js → cli-Cv0mpiRy.js} +191 -131
- package/dist/cli-Cv0mpiRy.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/{index-DGMxBsub.d.ts → index-IqoTGJFM.d.ts} +54 -8
- package/dist/index.d.ts +2 -2
- package/dist/index.js +37 -3
- package/dist/index.js.map +1 -1
- package/dist/{plugins-CGvM19v9.js → plugins-C2uwN3-D.js} +106 -30
- package/dist/plugins-C2uwN3-D.js.map +1 -0
- package/dist/{registration-CaE0QBT6.js → registration-mUZJidnF.js} +66 -7
- package/dist/registration-mUZJidnF.js.map +1 -0
- package/dist/{runtime-CZJeZvSL.js → runtime-B7mCDO5Q.js} +97 -16
- package/dist/runtime-B7mCDO5Q.js.map +1 -0
- package/dist/start.js +1 -1
- package/dist/testing.d.ts +14 -6
- package/dist/testing.js +2 -2
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
- package/dist/cli-Ce-ZUj6M.js.map +0 -1
- package/dist/plugins-CGvM19v9.js.map +0 -1
- package/dist/registration-CaE0QBT6.js.map +0 -1
- package/dist/runtime-CZJeZvSL.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
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-
|
|
3
|
-
import { i as createLogger, l as createSignals, n as createRuntime, r as manifestFiles, s as HandlerLoadError, t as LoginError, u as
|
|
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 =
|
|
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
|
-
|
|
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", `
|
|
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}"
|
|
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}"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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$
|
|
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
|
|
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",
|
|
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",
|
|
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", "
|
|
289
|
-
if (value.options !== void 0) ok = fail$1(ctx, "invalid-meta", "
|
|
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
|
|
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",
|
|
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}
|
|
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}"
|
|
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}
|
|
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}
|
|
339
|
-
for (const [locale, text] of Object.entries(value)) if (typeof text !== "string") return fail$1(ctx, "invalid-meta", `${label}.${locale}
|
|
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",
|
|
346
|
-
if (value.nsfw !== void 0 && typeof value.nsfw !== "boolean") ok = fail$1(ctx, "invalid-meta",
|
|
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}
|
|
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",
|
|
362
|
-
if (options.length > 25) return fail$1(ctx, "invalid-option",
|
|
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}
|
|
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}
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
394
|
-
|
|
395
|
-
|
|
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
|
|
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}
|
|
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}
|
|
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
|
|
422
|
-
if (typeof option.maxLength === "number" && (option.maxLength < 1 || option.maxLength > 6e3)) ok = fail$1(ctx, "invalid-option", `${label}.maxLength
|
|
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
|
|
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}
|
|
430
|
-
if (choices.length > 25) return fail$1(ctx, "invalid-option", `${label}
|
|
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}
|
|
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
|
|
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
|
|
442
|
-
} else if (typeof typed.value !== "number") ok = fail$1(ctx, "invalid-option", `${at}.value
|
|
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
|
|
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 (!
|
|
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
|
|
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", `
|
|
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
|
|
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", `
|
|
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
|
|
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",
|
|
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
|
-
|
|
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
|
|
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", `
|
|
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
|
-
|
|
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", `
|
|
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
|
|
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")}
|
|
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}"
|
|
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",
|
|
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$
|
|
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}"
|
|
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", `
|
|
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 (!
|
|
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", `
|
|
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
|
|
905
|
-
diagnostics.error("unknown-event", `"${name}"
|
|
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("
|
|
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("
|
|
923
|
-
if (meta.order !== void 0 && (typeof meta.order !== "number" || !Number.isFinite(meta.order))) return fail(
|
|
924
|
-
if (meta.mode !== void 0 && meta.mode !== "sequential" && meta.mode !== "concurrent") return fail(
|
|
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)}
|
|
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
|
-
|
|
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
|
-
|
|
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", `${
|
|
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
|
|
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", `
|
|
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)}
|
|
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
|
-
|
|
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
|
|
1333
|
+
* One diagnostic as a headline, an indented message, and the code's reference entry:
|
|
1276
1334
|
*
|
|
1277
|
-
* ✖ error invalid-name app/commands/
|
|
1278
|
-
*
|
|
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
|
-
|
|
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-
|
|
2285
|
+
//# sourceMappingURL=cli-Cv0mpiRy.js.map
|