meocord 4.1.2 → 4.2.1
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/CHANGELOG.md +362 -0
- package/THIRD_PARTY_NOTICES.md +14 -13
- package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-XKMlcUZz.cjs} +669 -185
- package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-BZgJyfZS.cjs} +218 -75
- package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
- package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CgfPQrNl.cjs} +374 -132
- package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-BUAR1WYG.cjs} +21 -7
- package/dist/cjs/common/index.cjs +4 -4
- package/dist/cjs/core/index.cjs +365 -310
- package/dist/cjs/decorator/index.cjs +85 -61
- package/dist/cjs/testing/index.cjs +2236 -496
- package/dist/cli.json +1 -1
- package/dist/esm/bin/app-template/README.md.template +3 -1
- package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
- package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
- package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
- package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
- package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
- package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
- package/dist/esm/bin/generator.js +4 -5
- package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
- package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
- package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
- package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
- package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
- package/dist/esm/bin/helper/service-generator.helper.js +4 -5
- package/dist/esm/bin/meocord.js +84 -25
- package/dist/esm/build/load-config.pre-entry.js +17 -4
- package/dist/esm/build/rsbuild-config.js +16 -12
- package/dist/esm/build/typed-externals.js +36 -0
- package/dist/esm/common/cooldown-store.js +10 -2
- package/dist/esm/common/decorator.js +6 -6
- package/dist/esm/common/deprecation.js +2 -2
- package/dist/esm/common/errors.js +27 -2
- package/dist/esm/common/execution-context.js +1 -1
- package/dist/esm/common/meocord-text.js +11 -8
- package/dist/esm/common/metadata.js +1 -1
- package/dist/esm/common/response/call-order.js +9 -4
- package/dist/esm/common/response/presenter.js +33 -6
- package/dist/esm/common/response/response-state.js +84 -10
- package/dist/esm/common/route.js +21 -5
- package/dist/esm/common/sharded-cooldown-store.js +1 -1
- package/dist/esm/common/translate-error.js +1 -1
- package/dist/esm/common/translator.js +37 -6
- package/dist/esm/core/command-conflicts.js +34 -34
- package/dist/esm/core/command-registration.js +93 -24
- package/dist/esm/core/command-routes.js +2 -2
- package/dist/esm/core/component-routes.js +74 -31
- package/dist/esm/core/cooldown-runner.js +29 -8
- package/dist/esm/core/dispatcher.js +51 -7
- package/dist/esm/core/fallback.js +20 -11
- package/dist/esm/core/filter-runner.js +2 -2
- package/dist/esm/core/guard-runner.js +16 -3
- package/dist/esm/core/handler-input.js +4 -3
- package/dist/esm/core/handler-pipeline.js +27 -14
- package/dist/esm/core/handler-registry.js +1 -1
- package/dist/esm/core/interceptor-runner.js +2 -2
- package/dist/esm/core/lifecycle-hooks.js +134 -47
- package/dist/esm/core/meocord-factory.js +241 -179
- package/dist/esm/core/meocord.app.js +116 -75
- package/dist/esm/core/message-routes.js +4 -3
- package/dist/esm/core/observer-runner.js +4 -3
- package/dist/esm/core/providers.js +45 -23
- package/dist/esm/core/route-specificity.js +4 -4
- package/dist/esm/core/running-calls.js +43 -0
- package/dist/esm/core/scalar-types.js +15 -3
- package/dist/esm/core/shard-context.js +6 -7
- package/dist/esm/core/shard-manager.js +16 -10
- package/dist/esm/core/startup-roots.js +23 -4
- package/dist/esm/core/theme-resolvers.js +4 -1
- package/dist/esm/core/theme-runtime.js +2 -2
- package/dist/esm/core/theme-scope.js +3 -1
- package/dist/esm/core/theme-validation.js +17 -10
- package/dist/esm/decorator/app.decorator.js +12 -7
- package/dist/esm/decorator/command-builder.decorator.js +4 -4
- package/dist/esm/decorator/controller-class.decorator.js +9 -4
- package/dist/esm/decorator/controller.decorator.js +162 -83
- package/dist/esm/decorator/cooldown.decorator.js +20 -3
- package/dist/esm/decorator/defer.decorator.js +4 -4
- package/dist/esm/decorator/event.decorator.js +5 -5
- package/dist/esm/decorator/filter.decorator.js +7 -7
- package/dist/esm/decorator/guard.decorator.js +11 -10
- package/dist/esm/decorator/inject.decorator.js +4 -4
- package/dist/esm/decorator/interceptor.decorator.js +7 -7
- package/dist/esm/decorator/observer.decorator.js +4 -4
- package/dist/esm/decorator/service.decorator.js +4 -4
- package/dist/esm/decorator/theme.decorator.js +3 -3
- package/dist/esm/decorator/validation.decorator.js +10 -10
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/catalog.js +27 -11
- package/dist/esm/testing/cooldown-store-suite.js +85 -15
- package/dist/esm/testing/discord-defaults.js +249 -21
- package/dist/esm/testing/index.js +4 -2
- package/dist/esm/testing/inspect-handler.js +20 -3
- package/dist/esm/testing/meocord-testing-module.js +352 -205
- package/dist/esm/testing/mock-fn.js +173 -22
- package/dist/esm/testing/mock-interaction.js +1214 -209
- package/dist/esm/testing/response.js +1 -1
- package/dist/esm/testing/routing.js +39 -14
- package/dist/esm/testing/snowflake.js +9 -2
- package/dist/esm/testing/startup-errors.js +26 -0
- package/dist/esm/testing/strict-mocks.js +65 -0
- package/dist/esm/testing/theme.js +1 -1
- package/dist/esm/util/bundle-entry.util.js +2 -1
- package/dist/esm/util/common.util.js +47 -19
- package/dist/esm/util/dev-runner.util.js +8 -8
- package/dist/esm/util/generator-cli.util.js +4 -5
- package/dist/esm/util/interaction.util.js +3 -3
- package/dist/esm/util/meocord-config-loader.util.js +10 -2
- package/dist/esm/util/meocord-config-validation.util.js +4 -5
- package/dist/esm/util/meocord-source-config.util.js +65 -20
- package/dist/esm/util/metadata-keys.js +1 -0
- package/dist/esm/util/method-wrapper.util.js +16 -0
- package/dist/esm/util/platform.util.js +10 -10
- package/dist/esm/util/refusal.util.js +148 -8
- package/dist/esm/util/shard-process.util.js +4 -1
- package/dist/esm/util/sharding-mode.util.js +5 -7
- package/dist/esm/util/shared-state.util.js +21 -0
- package/dist/esm/util/tsconfig-paths.util.js +29 -0
- package/dist/esm/util/tsconfig.util.js +10 -10
- package/dist/esm/util/value.util.js +12 -1
- package/dist/types/common/index.d.cts +8 -8
- package/dist/types/common/index.d.ts +8 -8
- package/dist/types/core/index.d.cts +10 -7
- package/dist/types/core/index.d.ts +10 -7
- package/dist/types/decorator/index.d.cts +31 -29
- package/dist/types/decorator/index.d.ts +31 -29
- package/dist/types/{index-D7n0FBMm.d.ts → index-DKJclIi-.d.ts} +107 -36
- package/dist/types/{index-DEP0NYrU.d.cts → index-Dlf0ZZtS.d.cts} +107 -36
- package/dist/types/interface/index.d.cts +1 -1
- package/dist/types/interface/index.d.ts +1 -1
- package/dist/types/{route-C-aMI7Sl.d.cts → route-Bh1U13yy.d.cts} +8 -4
- package/dist/types/{route-C-aMI7Sl.d.ts → route-Bh1U13yy.d.ts} +8 -4
- package/dist/types/testing/index.d.cts +406 -70
- package/dist/types/testing/index.d.ts +406 -70
- package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
- package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
- package/dist/types/{translator-J6tIUfdp.d.ts → translator-Bg7hwxDF.d.cts} +21 -8
- package/dist/types/{translator-B4mc1lzJ.d.cts → translator-CeP4bCzp.d.ts} +21 -8
- package/package.json +4 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Locale } from 'discord.js';
|
|
2
2
|
import { refuse } from '../util/refusal.util.js';
|
|
3
3
|
import { Logger } from './logger.js';
|
|
4
|
+
import { sharedName } from '../util/shared-state.util.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Declares a message catalog, keeping each message's text as its type so the params it takes can be checked.
|
|
@@ -61,8 +62,8 @@ import { Logger } from './logger.js';
|
|
|
61
62
|
/**
|
|
62
63
|
* Where a translator made by `createTranslator` keeps its catalogs, for MeoCord's texts, `@MeoCord` and
|
|
63
64
|
* `expectCompleteCatalog`.
|
|
64
|
-
*/ const CATALOGS = Symbol('catalogs');
|
|
65
|
-
/** How MeoCord's own texts read a translator made by `createTranslator`: the catalogs that serve a locale, in order. */ const LOCALE_CHAIN = Symbol('localeChain');
|
|
65
|
+
*/ const CATALOGS = Symbol.for(sharedName('translator:catalogs'));
|
|
66
|
+
/** How MeoCord's own texts read a translator made by `createTranslator`: the catalogs that serve a locale, in order. */ const LOCALE_CHAIN = Symbol.for(sharedName('translator:localeChain'));
|
|
66
67
|
/** For a class that injects `Translator` in an app that configured none. */ function missingTranslatorError(cls) {
|
|
67
68
|
return new Error(`${cls.name}: it injects Translator, but @MeoCord has no i18n. Pass @MeoCord({ i18n: t }), where t comes from createTranslator.`);
|
|
68
69
|
}
|
|
@@ -109,6 +110,22 @@ function interpolate(message, params) {
|
|
|
109
110
|
}
|
|
110
111
|
return typeof current === 'object' && current !== null;
|
|
111
112
|
}
|
|
113
|
+
/** The locale Discord shows a locale's users when that locale has no localization of its own, by its locale fallbacks. */ const FALLBACK_PARTNERS = {
|
|
114
|
+
[Locale.EnglishUS]: Locale.EnglishGB,
|
|
115
|
+
[Locale.EnglishGB]: Locale.EnglishUS,
|
|
116
|
+
[Locale.SpanishLATAM]: Locale.SpanishES
|
|
117
|
+
};
|
|
118
|
+
/** Each key with a `.` in it, which `lookup` splits as a path and so never reaches, with the group it sits in. */ function dottedKeys(catalog, group = '') {
|
|
119
|
+
return Object.entries(catalog).flatMap(([name, value])=>[
|
|
120
|
+
...name.includes('.') ? [
|
|
121
|
+
{
|
|
122
|
+
name,
|
|
123
|
+
group
|
|
124
|
+
}
|
|
125
|
+
] : [],
|
|
126
|
+
...typeof value === 'object' && value !== null ? dottedKeys(value, group ? `${group}.${name}` : name) : []
|
|
127
|
+
]);
|
|
128
|
+
}
|
|
112
129
|
class CatalogTranslator extends Translator {
|
|
113
130
|
/** The catalogs that serve a locale, most specific first, ending with the default. */ [LOCALE_CHAIN](requested) {
|
|
114
131
|
const chain = [];
|
|
@@ -127,10 +144,13 @@ class CatalogTranslator extends Translator {
|
|
|
127
144
|
translate(requested, key, params = {}) {
|
|
128
145
|
for (const locale of this[LOCALE_CHAIN](requested)){
|
|
129
146
|
const message = lookup(this.catalogs[locale], key);
|
|
130
|
-
if (message
|
|
147
|
+
if (message === undefined) continue;
|
|
148
|
+
// An empty translation, as tools export an untranslated string, counts as missing: Discord refuses an empty reply
|
|
149
|
+
const text = pluralForm({
|
|
131
150
|
message,
|
|
132
151
|
locale
|
|
133
|
-
}, params.count)
|
|
152
|
+
}, params.count);
|
|
153
|
+
if (text !== '') return interpolate(text, params);
|
|
134
154
|
}
|
|
135
155
|
this.warnUnknown(key);
|
|
136
156
|
return key;
|
|
@@ -160,11 +180,16 @@ class CatalogTranslator extends Translator {
|
|
|
160
180
|
for (const locale of this.locales){
|
|
161
181
|
if (locale === this.defaultLocale) continue;
|
|
162
182
|
const message = lookup(this.catalogs[locale], key);
|
|
163
|
-
// A translation with a {param} is left out
|
|
164
|
-
if (typeof message === 'string' && placeholderNames(message).length === 0) {
|
|
183
|
+
// A translation with a {param} is left out, as Discord would show it as written, and so is an empty one
|
|
184
|
+
if (typeof message === 'string' && message !== '' && placeholderNames(message).length === 0) {
|
|
165
185
|
localized[locale] = fillPlaceholders(message, (_name, whole)=>whole);
|
|
166
186
|
}
|
|
167
187
|
}
|
|
188
|
+
// Discord gives a locale it falls back from its partner's value, so the default keeps its own wording beside one
|
|
189
|
+
const partner = FALLBACK_PARTNERS[this.defaultLocale];
|
|
190
|
+
if (partner && localized[partner] !== undefined && typeof original === 'string' && original !== '') {
|
|
191
|
+
localized[this.defaultLocale] = fillPlaceholders(original, (_name, whole)=>whole);
|
|
192
|
+
}
|
|
168
193
|
return localized;
|
|
169
194
|
}
|
|
170
195
|
for(interaction, options = {}) {
|
|
@@ -243,6 +268,12 @@ class CatalogTranslator extends Translator {
|
|
|
243
268
|
}
|
|
244
269
|
}
|
|
245
270
|
if (!locales[defaultLocale]) throw refuse(new Error(`The default locale "${defaultLocale}" has no catalog in locales.`));
|
|
271
|
+
const logger = new Logger('Translator');
|
|
272
|
+
for (const [locale, catalog] of Object.entries(locales)){
|
|
273
|
+
for (const { name, group } of dottedKeys(catalog)){
|
|
274
|
+
logger.warn(`The ${locale} catalog's key "${name}"${group ? ` in ${group}` : ''} has a ".", which a lookup reads as a path, so its ` + 'message is never found. Nest it as a group instead.');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
246
277
|
// Typed by the default catalog; at runtime every catalog is read the same way.
|
|
247
278
|
return new CatalogTranslator(defaultLocale, locales);
|
|
248
279
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { ApplicationCommandType, ApplicationCommandOptionType } from 'discord.js';
|
|
2
2
|
import { Logger } from '../common/logger.js';
|
|
3
|
-
import {
|
|
3
|
+
import { getAutocompleteHandlers, getCommandMap, getDeclaredRoutes, getHandlerRoutes, getMessageHandlers, getReactionHandlers } from '../decorator/controller.decorator.js';
|
|
4
4
|
import { serialise, registrationKey, SENT_AS, commandNameOf } from './command-registration.js';
|
|
5
|
-
import { buildComponentRoutes, findComponentRouteConflicts
|
|
5
|
+
import { buildComponentRoutes, findComponentRouteConflicts } from './component-routes.js';
|
|
6
6
|
import { CommandType } from '../enum/controller.enum.js';
|
|
7
7
|
import { isCustomIdRouted } from '../util/interaction.util.js';
|
|
8
|
-
import {
|
|
8
|
+
import { startupError } from '../util/refusal.util.js';
|
|
9
|
+
import { META } from '../util/metadata-keys.js';
|
|
9
10
|
import { listed } from '../util/user-text.util.js';
|
|
10
11
|
|
|
11
12
|
const where = ({ controllerClass, meta })=>`${controllerClass.name}.${meta.methodName}`;
|
|
@@ -28,7 +29,7 @@ const where = ({ controllerClass, meta })=>`${controllerClass.name}.${meta.metho
|
|
|
28
29
|
* Refuses commands of which only one could run or be registered: two handlers of one slash command name or subcommand
|
|
29
30
|
* path or of one context menu name and kind, or two builders of one command; a command's subcommands, and a user and a
|
|
30
31
|
* message context menu of one name, are no clash. The app, `meocord register`, the shard manager and testing call it.
|
|
31
|
-
*
|
|
32
|
+
* Each clash is a startup error, naming both handlers or both builders with what to do instead; see `startupError`.
|
|
32
33
|
*/ function assertDistinctCommands(controllerClasses) {
|
|
33
34
|
const handlers = new Map();
|
|
34
35
|
const builders = new Map();
|
|
@@ -46,7 +47,8 @@ const where = ({ controllerClass, meta })=>`${controllerClass.name}.${meta.metho
|
|
|
46
47
|
const key = `${meta.type}\0${name}`;
|
|
47
48
|
const earlier = (handlers.get(key) ?? []).find((other)=>overlap(other.meta, meta));
|
|
48
49
|
if (earlier) {
|
|
49
|
-
|
|
50
|
+
startupError(new Error(`${where(earlier)}: it and ${where(here)} both handle the ${describe(meta.type, name, contextMenuKind(meta) ?? contextMenuKind(earlier.meta))}, ` + `so only ${where(earlier)} would ever run. Keep one handler for it, or give the other a name or ` + `subcommand path of its own.`));
|
|
51
|
+
continue;
|
|
50
52
|
}
|
|
51
53
|
handlers.set(key, [
|
|
52
54
|
...handlers.get(key) ?? [],
|
|
@@ -67,7 +69,7 @@ const where = ({ controllerClass, meta })=>`${controllerClass.name}.${meta.metho
|
|
|
67
69
|
name: typeof body.name === 'string' ? body.name : name
|
|
68
70
|
});
|
|
69
71
|
} else if (built.meta.builderClass !== meta.builderClass) {
|
|
70
|
-
|
|
72
|
+
startupError(new Error(`${where(built)}: its builder ${built.meta.builderClass.name} and ${meta.builderClass.name} on ${where(here)} both ` + `build the ${describe(meta.type, built.name, contextMenuKind(meta))}, and Discord registers one command ` + `per name and type, so only the first would be. Keep one builder, on a single @Command, and declare ` + `the other handlers with CommandType.${meta.type}.`));
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
}
|
|
@@ -245,18 +247,21 @@ const quoted = (names)=>names.length < 2 ? names.map((name)=>`"${name}"`).join('
|
|
|
245
247
|
/**
|
|
246
248
|
* Warns about a handler a subclass re-declares on other routes while it still answers the ones it inherits, for
|
|
247
249
|
* `@Command`, `@MessageHandler`, `@ReactionHandler` and `@Autocomplete` alike. The next major version (5.0) drops
|
|
248
|
-
* the inherited routes. Every class a listed controller extends is checked
|
|
250
|
+
* the inherited routes. Every class a listed controller extends is checked, a base that is not listed included, for
|
|
251
|
+
* the methods the controller still inherits through it.
|
|
249
252
|
*/ function warnInheritedRoutes(controllerClasses) {
|
|
250
|
-
|
|
251
|
-
const problems =
|
|
253
|
+
// A set, so a class two listed controllers extend is named once, for the routes either of them still answers
|
|
254
|
+
const problems = new Set();
|
|
252
255
|
for (const controllerClass of controllerClasses){
|
|
256
|
+
// Methods a class below re-declares with @Controller({ inheritedRoutes: 'replace' }): the listed controller inherits
|
|
257
|
+
// nothing of them from above it, so a class above is not named for them
|
|
258
|
+
const cut = new Set();
|
|
253
259
|
for(let prototype = controllerClass.prototype; prototype && prototype !== Object.prototype; prototype = Object.getPrototypeOf(prototype)){
|
|
254
|
-
if (checked.has(prototype)) break;
|
|
255
|
-
checked.add(prototype);
|
|
256
260
|
const declared = getDeclaredRoutes(prototype);
|
|
257
261
|
const base = Object.getPrototypeOf(prototype);
|
|
258
262
|
if (declared.length === 0 || !base) continue;
|
|
259
|
-
const
|
|
263
|
+
if (Reflect.getOwnMetadata(META.replacesInheritedRoutes, prototype)) for (const route of declared)cut.add(route.method);
|
|
264
|
+
const inherited = getHandlerRoutes(base).filter((route)=>!cut.has(route.method));
|
|
260
265
|
for (const method of new Set(declared.map((route)=>route.method))){
|
|
261
266
|
const own = [
|
|
262
267
|
...new Set(declared.filter((route)=>route.method === method).map((route)=>route.label))
|
|
@@ -266,17 +271,19 @@ const quoted = (names)=>names.length < 2 ? names.map((name)=>`"${name}"`).join('
|
|
|
266
271
|
].filter((label)=>!own.includes(label));
|
|
267
272
|
if (kept.length === 0) continue;
|
|
268
273
|
const name = prototype.constructor.name;
|
|
269
|
-
problems.
|
|
274
|
+
problems.add(` ${name}.${method} answers ${listed(kept)}, which it inherits, as well as its own ${listed(own)}.`);
|
|
270
275
|
}
|
|
271
276
|
}
|
|
272
277
|
}
|
|
273
|
-
if (problems.
|
|
274
|
-
const one = problems.
|
|
275
|
-
logger.warn(`${problems.
|
|
278
|
+
if (problems.size === 0) return;
|
|
279
|
+
const one = problems.size === 1;
|
|
280
|
+
logger.warn(`${problems.size} re-declared ${one ? 'handler still answers routes it inherits' : 'handlers still answer routes they inherit'}:\n` + `${[
|
|
281
|
+
...problems
|
|
282
|
+
].join('\n')}\nIn the next major version (5.0), a handler's own routes replace the ones it inherits. ` + "To keep an inherited route, declare it on the subclass's method as well; to drop it now, give the subclass " + "@Controller({ inheritedRoutes: 'replace' }).");
|
|
276
283
|
}
|
|
277
284
|
/**
|
|
278
|
-
* Warns of customId patterns of one component type that can match one customId, naming
|
|
279
|
-
*
|
|
285
|
+
* Warns of customId patterns of one component type that rank equally and can match one customId, naming an id both
|
|
286
|
+
* match and the handler that runs. An app with such a pair works, so it starts; the next major version (5.0) refuses.
|
|
280
287
|
* @throws Error for two handlers whose patterns match the same customIds, which the app refuses as well.
|
|
281
288
|
*/ function warnOverlappingPatterns(controllerClasses) {
|
|
282
289
|
const routes = buildComponentRoutes(controllerClasses);
|
|
@@ -286,23 +293,16 @@ const quoted = (names)=>names.length < 2 ? names.map((name)=>`"${name}"`).join('
|
|
|
286
293
|
`${route.meta.type}\0${route.pattern}`,
|
|
287
294
|
route
|
|
288
295
|
]));
|
|
289
|
-
const lines = conflicts.map(({ type, patterns: [left, right] })=>{
|
|
290
|
-
const outcome = ambiguityOutcome(routeOf.get(`${type}\0${left}`), routeOf.get(`${type}\0${right}`));
|
|
291
|
-
return ` "${left}" vs "${right}": ${outcome}`;
|
|
292
|
-
});
|
|
293
|
-
logger.warn(`${conflicts.length} pattern pair(s) can match the same customId, so which one runs is decided by ranking rather ` + `than by the ids themselves:\n${lines.join('\n')}`);
|
|
294
|
-
}
|
|
295
|
-
/**
|
|
296
|
-
* Which of two overlapping routes runs for the ids both match, and why: `runs` ranks first. Between equally
|
|
297
|
-
* specific patterns, the order they are listed in decides until 5.0, which prefers the earlier literal segment.
|
|
298
|
-
*/ function ambiguityOutcome(runs, other) {
|
|
299
296
|
const name = ({ controllerClass, meta })=>`${controllerClass.name}.${meta.methodName}`;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
297
|
+
const lines = conflicts.map(({ type, patterns: [left, right], customId })=>{
|
|
298
|
+
const [runs, other] = [
|
|
299
|
+
routeOf.get(`${type}\0${left}`),
|
|
300
|
+
routeOf.get(`${type}\0${right}`)
|
|
301
|
+
];
|
|
302
|
+
const listed = runs.controllerClass === other.controllerClass ? 'it is declared first' : 'its controller is listed first';
|
|
303
|
+
return ` "${left}" vs "${right}", which both match "${customId}": ${name(runs)} runs, as ${listed}.`;
|
|
304
|
+
});
|
|
305
|
+
logger.warn(`${conflicts.length} pattern pair(s) rank equally and can match the same customId, so the order they are listed in ` + `decides which one runs:\n${lines.join('\n')}\nMake the patterns distinct: in the next major version (5.0), ` + 'MeoCord refuses to start with such a pair.');
|
|
306
306
|
}
|
|
307
307
|
|
|
308
308
|
export { assertDistinctCommands, messageDecorator, reactionDecorator, warnHandlersOffControllers, warnInheritedRoutes, warnOverlappingPatterns, warnUnregisteredCommands };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
|
|
2
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { ApplicationCommandType, Routes } from 'discord.js';
|
|
5
5
|
import CliTable3 from 'cli-table3';
|
|
@@ -103,19 +103,35 @@ const scopeKey = (scope)=>scope === 'global' ? 'global' : `guild-${scope.guild}`
|
|
|
103
103
|
const guilds = options.config?.guilds ?? [];
|
|
104
104
|
return !everythingTarget(options) && guilds.length > 0 && ids(guilds).length === 0;
|
|
105
105
|
}
|
|
106
|
+
/** Whether a command is an activity's primary entry point, which Discord takes only globally. */ const isEntryPoint = (command)=>command.body.type === ApplicationCommandType.PrimaryEntryPoint;
|
|
107
|
+
/** What a blank guild to register every command to says, as an unset variable gives one. */ const BLANK_GUILD = 'No commands were registered: the guild to register to is blank. Give a guild id, or leave it out to register where commands.* says.';
|
|
106
108
|
/**
|
|
107
109
|
* Where each command is sent: one bulk update per scope. A scope's update replaces everything the application has
|
|
108
110
|
* there, so the default scope is included even when it ends up empty, unless `commands.guilds` lists no guild id.
|
|
111
|
+
*
|
|
112
|
+
* A primary entry point command goes in the global update, as Discord refuses a guild update that holds one. A run
|
|
113
|
+
* that sends everything to one guild sends nothing global, so it leaves the entry point out.
|
|
109
114
|
*/ function planTargets(commands, options, logger) {
|
|
115
|
+
// Registering to the default scopes instead could publish everywhere
|
|
116
|
+
if (options.onlyGuild !== undefined && !options.onlyGuild.trim()) {
|
|
117
|
+
logger.error(BLANK_GUILD);
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
110
120
|
const everythingTo = everythingTarget(options);
|
|
111
|
-
if (everythingTo)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
},
|
|
116
|
-
commands
|
|
121
|
+
if (everythingTo) {
|
|
122
|
+
// A global update from here would hold only what this run sends, deleting the application's other global commands
|
|
123
|
+
for (const command of commands.filter(isEntryPoint)){
|
|
124
|
+
logger.warn(`Entry point command "${command.name}" is registered globally only; a production run or \`meocord register\` registers it.`);
|
|
117
125
|
}
|
|
118
|
-
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
scope: {
|
|
129
|
+
guild: everythingTo
|
|
130
|
+
},
|
|
131
|
+
commands: commands.filter((command)=>!isEntryPoint(command))
|
|
132
|
+
}
|
|
133
|
+
];
|
|
134
|
+
}
|
|
119
135
|
const defaultGuilds = ids(options.config?.guilds);
|
|
120
136
|
const nowhere = defaultGuildsEmpty(options);
|
|
121
137
|
const targets = new Map();
|
|
@@ -133,7 +149,12 @@ const scopeKey = (scope)=>scope === 'global' ? 'global' : `guild-${scope.guild}`
|
|
|
133
149
|
});
|
|
134
150
|
const unregistered = [];
|
|
135
151
|
for (const command of commands){
|
|
136
|
-
if (command
|
|
152
|
+
if (isEntryPoint(command)) {
|
|
153
|
+
if (command.guilds) {
|
|
154
|
+
logger.warn(`Entry point command "${command.name}" can only be registered globally, so the guilds its builder lists are ignored.`);
|
|
155
|
+
}
|
|
156
|
+
add('global', command);
|
|
157
|
+
} else if (command.guilds) {
|
|
137
158
|
const own = ids(command.guilds);
|
|
138
159
|
// Falling back to the default scope could publish a staff-only command everywhere.
|
|
139
160
|
if (own.length === 0) logger.warn(`Command "${command.name}" lists no guild ids in its builder, so it is not registered.`);
|
|
@@ -151,7 +172,7 @@ const scopeKey = (scope)=>scope === 'global' ? 'global' : `guild-${scope.guild}`
|
|
|
151
172
|
}
|
|
152
173
|
}
|
|
153
174
|
if (nowhere) {
|
|
154
|
-
const outcome = unregistered.length === 0 ? 'nothing is registered globally' : `${unregistered.join(', ')} ${unregistered.length === 1 ? 'is' : 'are'} not registered, rather than registered globally`;
|
|
175
|
+
const outcome = unregistered.length === 0 ? targets.has('global') ? 'only the entry point command is registered globally' : 'nothing is registered globally' : `${unregistered.join(', ')} ${unregistered.length === 1 ? 'is' : 'are'} not registered, rather than registered globally`;
|
|
155
176
|
logger.warn(`commands.guilds lists no guild id, as an unset environment variable leaves it, so ${outcome}. ` + `Set the guild ids, or remove commands.guilds to register globally.`);
|
|
156
177
|
}
|
|
157
178
|
return [
|
|
@@ -180,28 +201,57 @@ const describeScope = (scope)=>scope === 'global' ? 'globally' : `to guild ${sco
|
|
|
180
201
|
return path.join(nodeModules, '.cache', 'meocord', `commands-${applicationId}-${scopeKey(scope)}.json`);
|
|
181
202
|
}
|
|
182
203
|
const hashOf = (commands)=>createHash('sha256').update(JSON.stringify(commands.map(({ body })=>body))).digest('hex');
|
|
183
|
-
function
|
|
204
|
+
function readRecord(file, logger) {
|
|
184
205
|
if (!file || !existsSync(file)) return undefined;
|
|
185
206
|
try {
|
|
186
|
-
return JSON.parse(readFileSync(file, 'utf8'))
|
|
207
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
187
208
|
} catch (error) {
|
|
188
209
|
logger.debug(`Could not read the registered commands, so they are sent again: ${String(error)}`);
|
|
189
210
|
return undefined;
|
|
190
211
|
}
|
|
191
212
|
}
|
|
192
|
-
function
|
|
213
|
+
function writeRecord(file, record, logger) {
|
|
193
214
|
if (!file) return;
|
|
194
215
|
try {
|
|
195
216
|
mkdirSync(path.dirname(file), {
|
|
196
217
|
recursive: true
|
|
197
218
|
});
|
|
198
|
-
writeFileSync(file, `${JSON.stringify(
|
|
199
|
-
hash
|
|
200
|
-
})}\n`);
|
|
219
|
+
writeFileSync(file, `${JSON.stringify(record)}\n`);
|
|
201
220
|
} catch (error) {
|
|
202
221
|
logger.debug(`Could not record the registered commands: ${String(error)}`);
|
|
203
222
|
}
|
|
204
223
|
}
|
|
224
|
+
/** Forgets what development last sent to a scope, once that scope is written some other way. */ function forgetRecord(file, logger) {
|
|
225
|
+
if (!file || !existsSync(file)) return;
|
|
226
|
+
try {
|
|
227
|
+
rmSync(file);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
logger.debug(`Could not forget the registered commands: ${String(error)}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Each command's `id:version`, sorted, from what Discord lists or answers a bulk update with; `undefined` for anything
|
|
234
|
+
* else. Discord gives a command a new version whenever it is updated, so the set changes with any edit, removal or
|
|
235
|
+
* re-registration, from this checkout or any other.
|
|
236
|
+
*/ function versionsOf(commands) {
|
|
237
|
+
if (!Array.isArray(commands)) return undefined;
|
|
238
|
+
const versions = commands.map((command)=>{
|
|
239
|
+
const { id, version } = command ?? {};
|
|
240
|
+
return typeof id === 'string' && typeof version === 'string' ? `${id}:${version}` : undefined;
|
|
241
|
+
});
|
|
242
|
+
return versions.every((version)=>version !== undefined) ? versions.sort() : undefined;
|
|
243
|
+
}
|
|
244
|
+
/** Whether Discord still holds a scope as development last sent it, by each command's version; a failed listing doesn't. */ async function stillAsSent(rest, route, record, scope, logger) {
|
|
245
|
+
if (!record.versions) return false;
|
|
246
|
+
let registered;
|
|
247
|
+
try {
|
|
248
|
+
registered = await rest.get(route);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
logger.debug(`Could not list the commands registered ${describeScope(scope)}, so they are sent again: ${String(error)}`);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
return versionsOf(registered)?.join('\n') === record.versions.join('\n');
|
|
254
|
+
}
|
|
205
255
|
function logRegistered(target, logger) {
|
|
206
256
|
const table = new CliTable3({
|
|
207
257
|
head: [
|
|
@@ -224,22 +274,29 @@ function logRegistered(target, logger) {
|
|
|
224
274
|
};
|
|
225
275
|
for (const { name, body } of target.commands){
|
|
226
276
|
const options = Array.isArray(body.options) ? body.options : [];
|
|
277
|
+
// A subcommand or a subcommand group, not a plain option
|
|
278
|
+
const subcommands = options.filter((option)=>option.type === 1 || option.type === 2);
|
|
227
279
|
table.push([
|
|
228
280
|
name,
|
|
229
281
|
typeNames[typeof body.type === 'number' ? body.type : 1] ?? 'Command',
|
|
230
|
-
|
|
282
|
+
subcommands.map((option)=>option.name).join(', ')
|
|
231
283
|
]);
|
|
232
284
|
}
|
|
233
285
|
logger.log(`Registered ${target.commands.length} bot commands ${describeScope(target.scope)}:\n${table.toString()}`);
|
|
234
286
|
}
|
|
235
287
|
/**
|
|
236
|
-
* Registers the commands, one bulk update per scope; development skips a scope unchanged since its last send
|
|
237
|
-
* `force`. Once one is sent, and not to `onlyGuild`, the other scopes the configuration names are checked for
|
|
288
|
+
* Registers the commands, one bulk update per scope; development skips a scope unchanged since its last send and still
|
|
289
|
+
* registered as sent, which one listing per skipped scope checks by each command's version, unless `force`. A blank `onlyGuild` registers nothing. Once one is sent, and not to `onlyGuild`, the other scopes the configuration names are checked for
|
|
238
290
|
* leftovers, which `clearOther` removes; a development-guild run or a `commands.guilds` with no id only warns. Never
|
|
239
291
|
* throws.
|
|
240
292
|
* @returns Whether every scope was sent or unchanged, and `commands.guilds`, if set, lists a guild id.
|
|
241
293
|
*/ async function registerCommands(options) {
|
|
242
294
|
const { rest, applicationId, controllerClasses, logger, config, development, onlyGuild, force = false } = options;
|
|
295
|
+
// An unset variable passed as the guild; registering to the default scopes instead could publish everywhere
|
|
296
|
+
if (onlyGuild !== undefined && !onlyGuild.trim()) {
|
|
297
|
+
logger.error(BLANK_GUILD);
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
243
300
|
const commands = collectCommands(controllerClasses, logger);
|
|
244
301
|
if (!commands) return false;
|
|
245
302
|
const targets = planTargets(commands, {
|
|
@@ -256,18 +313,26 @@ function logRegistered(target, logger) {
|
|
|
256
313
|
let succeeded = !guildsEmpty;
|
|
257
314
|
let sent = false;
|
|
258
315
|
for (const target of targets){
|
|
259
|
-
const file =
|
|
316
|
+
const file = cacheFile(applicationId, target.scope);
|
|
260
317
|
const hash = hashOf(target.commands);
|
|
261
|
-
|
|
318
|
+
const route = routeFor(applicationId, target.scope);
|
|
319
|
+
// Unchanged since development last sent it, and still what Discord holds there
|
|
320
|
+
const record = development && !force ? readRecord(file, logger) : undefined;
|
|
321
|
+
if (record?.hash === hash && await stillAsSent(rest, route, record, target.scope, logger)) {
|
|
262
322
|
logger.log(`Commands ${describeScope(target.scope)} are unchanged; not registering them again.`);
|
|
263
323
|
continue;
|
|
264
324
|
}
|
|
265
325
|
try {
|
|
266
|
-
await rest.put(
|
|
326
|
+
const answered = await rest.put(route, {
|
|
267
327
|
body: target.commands.map(({ body })=>body)
|
|
268
328
|
});
|
|
269
329
|
sent = true;
|
|
270
|
-
|
|
330
|
+
// Outside development the record would be stale, so the next development start sends again
|
|
331
|
+
if (development) writeRecord(file, {
|
|
332
|
+
hash,
|
|
333
|
+
versions: versionsOf(answered)
|
|
334
|
+
}, logger);
|
|
335
|
+
else forgetRecord(file, logger);
|
|
271
336
|
logRegistered(target, logger);
|
|
272
337
|
} catch (error) {
|
|
273
338
|
succeeded = false;
|
|
@@ -296,13 +361,17 @@ function logRegistered(target, logger) {
|
|
|
296
361
|
if (!Array.isArray(existing) || existing.length === 0) continue;
|
|
297
362
|
const names = existing.map(({ name })=>name).join(', ');
|
|
298
363
|
if (!config?.clearOther || keptBecause) {
|
|
299
|
-
|
|
364
|
+
// Advising clearOther when it would not apply sends the reader to a setting that changes nothing
|
|
365
|
+
const advice = !keptBecause ? 'Set commands.clearOther to remove them.' : config?.clearOther ? `clearOther is on, but they are not removed ${keptBecause}.` : `They are not removed ${keptBecause}, even with commands.clearOther.`;
|
|
366
|
+
logger.warn(`${existing.length} command(s) are still registered ${describeScope(scope)} (${names}), which this ` + `configuration does not register to, so Discord keeps showing them there. ${advice}`);
|
|
300
367
|
continue;
|
|
301
368
|
}
|
|
302
369
|
try {
|
|
303
370
|
await rest.put(routeFor(applicationId, scope), {
|
|
304
371
|
body: []
|
|
305
372
|
});
|
|
373
|
+
// What development last sent there is gone, so its next start sends again
|
|
374
|
+
forgetRecord(cacheFile(applicationId, scope), logger);
|
|
306
375
|
logger.log(`Removed ${existing.length} command(s) left registered ${describeScope(scope)}: ${names}`);
|
|
307
376
|
} catch (error) {
|
|
308
377
|
logger.error(`Error removing the commands left registered ${describeScope(scope)}:`, error);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getCommandMap } from '../decorator/controller.decorator.js';
|
|
1
|
+
import { commandsNamed, getCommandMap } from '../decorator/controller.decorator.js';
|
|
2
2
|
import { resolveCommandPaths, matchesHandler } from '../util/interaction.util.js';
|
|
3
3
|
|
|
4
4
|
/** The keys a command is routed by, most specific first: a chat command's full path, then its name; none for anything else. */ function commandRouteKeys(interaction) {
|
|
@@ -15,7 +15,7 @@ import { resolveCommandPaths, matchesHandler } from '../util/interaction.util.js
|
|
|
15
15
|
*/ function matchCommandRoute(controllers, interaction) {
|
|
16
16
|
for (const key of commandRouteKeys(interaction)){
|
|
17
17
|
for (const controllerClass of controllers){
|
|
18
|
-
const meta = getCommandMap(controllerClass.prototype)
|
|
18
|
+
const meta = commandsNamed(getCommandMap(controllerClass.prototype), key).find((each)=>matchesHandler(each, interaction));
|
|
19
19
|
if (meta) return {
|
|
20
20
|
controllerClass,
|
|
21
21
|
meta
|
|
@@ -1,10 +1,36 @@
|
|
|
1
|
-
import { getCommandMap, patternShape,
|
|
1
|
+
import { getCommandMap, patternShape, paramNarrowness, sharedCustomId, createRegexFromPattern } from '../decorator/controller.decorator.js';
|
|
2
2
|
import { decodeRouteParams } from '../common/route.js';
|
|
3
3
|
import { parseSegment } from './scalar-types.js';
|
|
4
4
|
import { refuse } from '../util/refusal.util.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* How a pattern ranks, one character per segment twice over: `L` for literal text or `P` for a param, then how narrow
|
|
8
|
+
* each param is, the narrowest as the lowest digit. Two keys of one length compare as text: literal before param at
|
|
9
|
+
* the first segment where they differ, then the narrower type.
|
|
10
|
+
*/ function rankKey(pattern) {
|
|
11
|
+
const narrowness = pattern.split('/').map(paramNarrowness);
|
|
12
|
+
const kinds = narrowness.map((rank)=>rank === undefined ? 'L' : 'P').join('');
|
|
13
|
+
return `${kinds}/${narrowness.map((rank)=>String(rank === undefined ? 0 : 9 - rank)).join('')}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Orders two routes as dispatch tries them. Left to right, the first segment one pattern spells out as literal text
|
|
17
|
+
* while the other leaves it to a param ranks the literal one first: `a/{x}` before `{x}/abcd`, `profile/me/{section}`
|
|
18
|
+
* before `profile/{userId}/edit`. Between patterns that leaves tied, the first param where one type is narrower ranks
|
|
19
|
+
* it first: words to choose from, then `bool`, `uuid`, `snowflake`, `int`, `number`, then text. Patterns of different
|
|
20
|
+
* segment counts, which no customId matches both of, are ordered by count only so the order is total; component
|
|
21
|
+
* patterns have no catch-all param. What remains tied keeps its listing order.
|
|
22
|
+
*/ function rank(keys) {
|
|
23
|
+
return (a, b)=>{
|
|
24
|
+
const [left, right] = [
|
|
25
|
+
keys.get(a),
|
|
26
|
+
keys.get(b)
|
|
27
|
+
];
|
|
28
|
+
if (left.length !== right.length) return left.length - right.length;
|
|
29
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Every customId-pattern route of the given controllers, in the order dispatch tries them, read from metadata alone.
|
|
8
34
|
* Throws for two handlers whose patterns match the same customIds of one component type; one handler
|
|
9
35
|
* declared under two spellings of a pattern keeps one route.
|
|
10
36
|
*/ function buildComponentRoutes(controllerClasses) {
|
|
@@ -37,22 +63,11 @@ import { refuse } from '../util/refusal.util.js';
|
|
|
37
63
|
}
|
|
38
64
|
}
|
|
39
65
|
}
|
|
40
|
-
// A stable sort, so
|
|
41
|
-
return routes.sort((
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
* parameter, the tie-break between equally specific patterns in the next major version (5.0).
|
|
46
|
-
*/ function literalFirst(a, b) {
|
|
47
|
-
const [left, right] = [
|
|
48
|
-
a.split('/'),
|
|
49
|
-
b.split('/')
|
|
50
|
-
];
|
|
51
|
-
for(let i = 0; i < Math.min(left.length, right.length); i++){
|
|
52
|
-
const leftLiteral = !left[i].includes('{');
|
|
53
|
-
if (leftLiteral !== !right[i].includes('{')) return leftLiteral;
|
|
54
|
-
}
|
|
55
|
-
return false;
|
|
66
|
+
// A stable sort, so what the ranking leaves tied keeps the order controllers and handlers are listed in
|
|
67
|
+
return routes.sort(rank(new Map(routes.map((route)=>[
|
|
68
|
+
route,
|
|
69
|
+
rankKey(route.pattern)
|
|
70
|
+
]))));
|
|
56
71
|
}
|
|
57
72
|
/** A component type as an error names it: `button`, `modal submit`, `select menu`. */ const typeLabel = (type)=>type.toLowerCase().replaceAll('_', ' ');
|
|
58
73
|
/**
|
|
@@ -77,6 +92,12 @@ const typesByPattern = new Map();
|
|
|
77
92
|
if (!types) typesByPattern.set(pattern, types = createRegexFromPattern(pattern).types);
|
|
78
93
|
return types;
|
|
79
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* The params `pattern`, compiled as `regex`, captures from a customId, each typed param as its value:
|
|
97
|
+
* `undefined` when the customId does not match, or a segment is not a value of its param's type.
|
|
98
|
+
*/ function readCustomId(pattern, regex, customId) {
|
|
99
|
+
return readCustomIdSegments(pattern, regex, customId)?.values;
|
|
100
|
+
}
|
|
80
101
|
/** {@link readCustomId}'s values, with each param's text as the customId gave it. */ function readCustomIdSegments(pattern, regex, customId) {
|
|
81
102
|
const match = regex.exec(customId);
|
|
82
103
|
if (!match) return undefined;
|
|
@@ -94,18 +115,40 @@ const typesByPattern = new Map();
|
|
|
94
115
|
text
|
|
95
116
|
};
|
|
96
117
|
}
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Pattern pairs of one component type that the ranking leaves tied and that can match one customId, each with the
|
|
120
|
+
* pattern that runs, the one ranked first in `routes`, and an id both match. Only patterns of one rank key, read
|
|
121
|
+
* together, can tie, so each group is compared within itself.
|
|
122
|
+
*/ function findComponentRouteConflicts(routes) {
|
|
123
|
+
const groups = new Map();
|
|
124
|
+
for (const route of routes){
|
|
125
|
+
// The literal text and each param's narrowness: patterns that differ in either rank apart or match no id in common
|
|
126
|
+
const key = `${route.meta.type}\0${route.pattern.split('/').map((segment)=>paramNarrowness(segment) ?? `=${segment}`).join('\0')}`;
|
|
127
|
+
groups.set(key, [
|
|
128
|
+
...groups.get(key) ?? [],
|
|
129
|
+
route
|
|
130
|
+
]);
|
|
131
|
+
}
|
|
132
|
+
const conflicts = [];
|
|
133
|
+
for (const group of groups.values()){
|
|
134
|
+
for(let i = 0; i < group.length; i++){
|
|
135
|
+
for(let j = i + 1; j < group.length; j++){
|
|
136
|
+
const customId = sharedCustomId(group[i].pattern, group[j].pattern);
|
|
137
|
+
if (customId === undefined) continue;
|
|
138
|
+
const patterns = [
|
|
139
|
+
group[i].pattern,
|
|
140
|
+
group[j].pattern
|
|
141
|
+
];
|
|
142
|
+
conflicts.push({
|
|
143
|
+
type: group[i].meta.type,
|
|
144
|
+
patterns,
|
|
145
|
+
runs: group[i].pattern,
|
|
146
|
+
customId
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return conflicts;
|
|
109
152
|
}
|
|
110
153
|
|
|
111
|
-
export { buildComponentRoutes, findComponentRouteConflicts,
|
|
154
|
+
export { buildComponentRoutes, findComponentRouteConflicts, matchComponentRoute, readCustomId };
|