meocord 4.1.0-beta.8 → 4.1.0-beta.9

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/cjs/_shared/{providers-CSLRsJOZ.cjs → providers-CyQkBU8g.cjs} +1 -1
  3. package/dist/cjs/_shared/{shard-context-DV-BymqL.cjs → shard-context-BWzzLmaJ.cjs} +12 -10
  4. package/dist/cjs/_shared/{sharded-cooldown-store-CBKsDtvc.cjs → sharded-cooldown-store-ComEj8So.cjs} +3 -3
  5. package/dist/cjs/_shared/{theme-resolvers-CVm6n5-Q.cjs → theme-resolvers-DLgZL-uE.cjs} +24 -3
  6. package/dist/cjs/_shared/{translate-error-Bts5vGXt.cjs → translate-error-yLJa8q4Q.cjs} +1 -1
  7. package/dist/cjs/common/index.cjs +3 -3
  8. package/dist/cjs/core/index.cjs +12 -11
  9. package/dist/cjs/decorator/index.cjs +2 -2
  10. package/dist/cjs/testing/index.cjs +89 -41
  11. package/dist/cli.json +1 -1
  12. package/dist/esm/bin/generator.js +2 -0
  13. package/dist/esm/bin/helper/filter-generator.helper.js +2 -0
  14. package/dist/esm/bin/helper/guard-generator.helper.js +2 -0
  15. package/dist/esm/bin/helper/interceptor-generator.helper.js +2 -0
  16. package/dist/esm/bin/helper/observer-generator.helper.js +2 -0
  17. package/dist/esm/bin/helper/pipe-generator.helper.js +2 -0
  18. package/dist/esm/bin/helper/service-generator.helper.js +2 -0
  19. package/dist/esm/bin/meocord.js +2 -1
  20. package/dist/esm/build/rsbuild-config.js +3 -0
  21. package/dist/esm/common/translator.js +23 -3
  22. package/dist/esm/core/component-routes.js +2 -1
  23. package/dist/esm/core/dispatcher.js +1 -1
  24. package/dist/esm/core/handler-pipeline.js +6 -5
  25. package/dist/esm/core/meocord-factory.js +3 -2
  26. package/dist/esm/core/meocord.app.js +3 -3
  27. package/dist/esm/core/shard-context.js +3 -0
  28. package/dist/esm/package.json.js +1 -1
  29. package/dist/esm/testing/catalog.js +38 -7
  30. package/dist/esm/testing/mock-interaction.js +49 -32
  31. package/dist/esm/util/generator-cli.util.js +2 -0
  32. package/dist/esm/util/platform.util.js +2 -1
  33. package/dist/esm/util/sharding-mode.util.js +4 -2
  34. package/dist/types/common/index.d.cts +2 -2
  35. package/dist/types/common/index.d.ts +2 -2
  36. package/dist/types/core/index.d.cts +3 -2
  37. package/dist/types/core/index.d.ts +3 -2
  38. package/dist/types/testing/index.d.cts +26 -11
  39. package/dist/types/testing/index.d.ts +26 -11
  40. package/dist/types/{translator-SbY8yk6y.d.ts → translator-Caddk39-.d.ts} +58 -7
  41. package/dist/types/{translator-CmWsC1-t.d.cts → translator-dZuNvbo2.d.cts} +58 -7
  42. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # meocord
2
2
 
3
+ ## 4.1.0-beta.9
4
+
5
+ ### Minor Changes
6
+
7
+ - [#280](https://github.com/meocord/meocord/pull/280) [`c79cd44`](https://github.com/meocord/meocord/commit/c79cd44c4badc87f26a936e57fa1cc33f4cc4136) Thanks [@l7aromeo](https://github.com/l7aromeo)! - A translation's `{params}` are checked against the default catalog's. Until now only its keys were, so a translation that misspelt a param, such as `'Diblokir {usr}.'` for `'Banned {user}.'`, compiled and showed the user `{usr}` as written.
8
+
9
+ - **When the code compiles**, `createTranslator` refuses a message that uses a `{param}` its default message doesn't take, and a plural's form may use `{count}` besides. The error names each one, such as `id: ban.done takes no {usr}; the default is "Banned {user}."`. A translation may use the default's params in any order, and leave some out. The compiler reads a message's params only from a catalog whose text it keeps: one made with `defineCatalog`, written with `as const`, or written inline.
10
+ - **When a test runs**, `expectCompleteCatalog` from `meocord/testing` reports the same mistakes from the catalogs' own strings, so a catalog from a plain variable or a JSON file is checked too. It also reports a `{param}` that a translation of MeoCord's own texts uses and MeoCord's English doesn't take.
11
+
12
+ A `{param}` is the same thing in every check and when translating: ASCII letters, digits or `_` between braces. Other text in braces, such as `Wrap text in { and }.`, is the message's own. So a default message with such braces no longer makes `t('wrap')` ask for params it doesn't use. A message may also hold hundreds of params: one with 47 or more failed to compile with "Type instantiation is excessively deep and possibly infinite".
13
+
14
+ If your build or a test now fails, rename the param to the one the default message uses. A translation may leave a param out, but it can't add one: the translator only fills the params the default message names.
15
+
16
+ - [#281](https://github.com/meocord/meocord/pull/281) [`c59ddc2`](https://github.com/meocord/meocord/commit/c59ddc28ab25a123c87fbb4f6c6dd7f394a63efb) Thanks [@l7aromeo](https://github.com/l7aromeo)! - `createMockMessage` takes an `author`, so a test can send several messages as one user. Before, every mock message came from a new person, so a per-user `@Cooldown` or a check on who sent a message couldn't be tested through `dispatch()` without assigning `message.author` by hand.
17
+
18
+ ```ts
19
+ const author = createMockUser()
20
+ await module.dispatch(createMockMessage({ author, content: '!daily' }))
21
+ await module.dispatch(createMockMessage({ author, content: '!daily' })) // refused by the cooldown
22
+ ```
23
+
24
+ - The author is cached on the message's client, so a mention of it resolves to the same user.
25
+ - In a server, the message's `member` is the guild's cached member for that user, such as one given to `createMockGuild({ members })`, or a new member with the author's id, which is then cached. Every message from that author in one server (the same `guild` given to each) has the same member.
26
+ - `author: client.user` gives a message the bot itself sent.
27
+ - An interaction's `member` works the same way: for a `user` the test gives, it is the `guild`'s cached member for that user, or a new member with its id, user and guild, which is then cached. A message and an interaction from one user in one server share the member, whichever is made first.
28
+ - A message built without `author` is unchanged.
29
+
30
+ ### Patch Changes
31
+
32
+ - [#277](https://github.com/meocord/meocord/pull/277) [`55fe3e4`](https://github.com/meocord/meocord/commit/55fe3e4db223b342b7951e8e36e83cd479cfd21d) Thanks [@l7aromeo](https://github.com/l7aromeo)! - More mistakes MeoCord refuses as the bot loads are reported as one line, naming what to change first, instead of `Error during startup:` and a stack. This covers two classes of one name when either uses `@Cooldown` or `@Once`, `@Validate`, `@UsePipe` or `@Cooldown` on a handler they don't apply to, two component `customId` patterns that match the same ids, `sharding` settings in `meocord.config.ts` that `clientOptions` contradicts, and a self-contained build started on a platform its native addons weren't built for.
33
+
34
+ - Two component patterns that match the same ids, and the warning about two that can, are now reported by `MeoCordFactory.create()`, before `start()` attaches anything, and `meocord register` reports them too.
35
+ - The messages lead with the class, handler or file they are about, such as `Shop: two classes have this name; …`. A test that matches the old wording needs updating.
36
+
37
+ - [#278](https://github.com/meocord/meocord/pull/278) [`2d26fc3`](https://github.com/meocord/meocord/commit/2d26fc397808f6888405690a5dbeedf3e912f93e) Thanks [@l7aromeo](https://github.com/l7aromeo)! - A production build keeps every class's own name. Before, when two modules declared a class of the same name, even a helper that never reaches MeoCord, `meocord build --prod` renamed one of them, such as `Shop` to `shop_controller_Shop`. A development build and your tests kept `Shop`. So in production, cooldowns were counted under the renamed class, errors and logs named it, and `ExecutionContext.getClass().name` returned it.
38
+
39
+ - If a controller was renamed this way, its cooldowns start over once, when you deploy this version. Nothing to do: the counts under the old name expire on their own.
40
+ - Two classes of one name are now refused in production as they already were in development and tests. That covers two controllers where either uses `@Cooldown` or `@Once`, and any two controllers or services under process sharding. If your bot stops at startup with this refusal, rename one of the two classes; the message names it.
41
+
3
42
  ## 4.1.0-beta.8
4
43
 
5
44
  ### Minor Changes
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  require('reflect-metadata');
4
- var themeResolvers = require('./theme-resolvers-CVm6n5-Q.cjs');
4
+ var themeResolvers = require('./theme-resolvers-DLgZL-uE.cjs');
5
5
  var discord_js = require('discord.js');
6
6
  var enum_index = require('../enum/index.cjs');
7
7
  var node_async_hooks = require('node:async_hooks');
@@ -1,9 +1,9 @@
1
1
  'use strict';
2
2
 
3
- var themeResolvers = require('./theme-resolvers-CVm6n5-Q.cjs');
4
- var translateError = require('./translate-error-Bts5vGXt.cjs');
3
+ var themeResolvers = require('./theme-resolvers-DLgZL-uE.cjs');
4
+ var translateError = require('./translate-error-yLJa8q4Q.cjs');
5
5
  var discord_js = require('discord.js');
6
- var providers = require('./providers-CSLRsJOZ.cjs');
6
+ var providers = require('./providers-CyQkBU8g.cjs');
7
7
  require('reflect-metadata');
8
8
  var enum_index = require('../enum/index.cjs');
9
9
  var crypto = require('crypto');
@@ -11,6 +11,8 @@ var fs = require('fs');
11
11
  var path = require('path');
12
12
  var Table = require('cli-table3');
13
13
  require('node:crypto');
14
+ require('node:fs');
15
+ require('node:url');
14
16
 
15
17
  const NO_GLOBAL_STAGES = {
16
18
  guards: [],
@@ -21,7 +23,7 @@ const NO_GLOBAL_STAGES = {
21
23
  /** The global stages declared by `@MeoCord` on an application class. */ function appStages(app) {
22
24
  const options = Reflect.getMetadata(enum_index.MetadataKey.AppOptions, app);
23
25
  if (!options) {
24
- throw new Error(`${app.name || 'The app'} is not decorated with @MeoCord().`);
26
+ throw themeResolvers.refuse(new Error(`${app.name || 'The app class'}: not decorated with @MeoCord().`));
25
27
  }
26
28
  return {
27
29
  guards: [
@@ -133,7 +135,7 @@ const stagesByGlobals = new WeakMap();
133
135
  for (const cls of classes){
134
136
  const other = byName.get(cls.name);
135
137
  if (other && other !== cls && (keyedByName(cls) || keyedByName(other))) {
136
- throw new Error(`Two classes are named ${cls.name}, and @Cooldown and @Once tell classes apart by name, so they would share counts. ` + `Rename one of them.`);
138
+ throw themeResolvers.refuse(new Error(`${cls.name}: two classes have this name; @Cooldown and @Once tell classes apart by name, so they would share ` + `their counts. Rename one of them.`));
137
139
  }
138
140
  byName.set(cls.name, cls);
139
141
  }
@@ -159,13 +161,13 @@ const stagesByGlobals = new WeakMap();
159
161
  ];
160
162
  for (const [method, kind] of others){
161
163
  const { schema, pipes } = providers.handlerInputStages(prototype, method);
164
+ const handler = kind === 'message' ? 'a message handler without a pattern' : `${kind === 'autocomplete' || kind === 'event' ? 'an' : 'a'} ${kind} handler`;
162
165
  if (schema || pipes.length > 0) {
163
- const handler = kind === 'message' ? 'a message handler without a pattern' : `${kind === 'autocomplete' || kind === 'event' ? 'an' : 'a'} ${kind} handler`;
164
- throw new Error(`${controller.name}.${method} is ${handler}; @Validate and @UsePipe apply only to interaction and patterned ` + `message handlers, whose options, customId params, modal fields and pattern params they check.`);
166
+ throw themeResolvers.refuse(new Error(`${controller.name}.${method}: @Validate and @UsePipe are for interaction and patterned message handlers, ` + `whose options, customId params, modal fields and pattern params they check, and this is ${handler}.`));
165
167
  }
166
168
  // A controller's own @Cooldown skips these handlers; one on the method itself is a mistake.
167
169
  if (kind !== 'message' && providers.methodCooldowns(prototype, method).length > 0) {
168
- throw new Error(`${controller.name}.${method} is ${kind === 'autocomplete' || kind === 'event' ? 'an' : 'a'} ${kind} handler; @Cooldown applies only to ` + `interaction and message handlers.`);
170
+ throw themeResolvers.refuse(new Error(`${controller.name}.${method}: @Cooldown is for interaction and message handlers, and this is ${handler}.`));
169
171
  }
170
172
  }
171
173
  }
@@ -1129,7 +1131,7 @@ function logRegistered(target, logger) {
1129
1131
  }
1130
1132
  // One handler under two spellings, such as 'card/{id}' and 'card/{cardId}', is one route
1131
1133
  if (earlier.controllerClass === controllerClass && earlier.meta.methodName === meta.methodName) continue;
1132
- throw new Error(`"${earlier.pattern}" in ${earlier.controllerClass.name}.${earlier.meta.methodName} and "${pattern}" in ` + `${controllerClass.name}.${meta.methodName} match the same ${typeLabel(meta.type)} customIds, so only one ` + `of them could ever run. Change one pattern.`);
1134
+ throw themeResolvers.refuse(new Error(`${earlier.controllerClass.name}.${earlier.meta.methodName}: "${earlier.pattern}" and "${pattern}" in ` + `${controllerClass.name}.${meta.methodName} match the same ${typeLabel(meta.type)} customIds, so only one ` + `of them could ever run. Change one pattern.`));
1133
1135
  }
1134
1136
  }
1135
1137
  }
@@ -2386,7 +2388,7 @@ const sortedEntries = (entries)=>entries.sort((a, b)=>a.usage < b.usage ? -1 : a
2386
2388
  }
2387
2389
  return this.controllerInstancesCache.get(controllerClass);
2388
2390
  }
2389
- /** The component routes, built and checked for overlaps on first use; the app calls it at startup. */ getComponentRoutes() {
2391
+ /** The component routes, built and checked for overlaps on first use; the app calls it as it is created. */ getComponentRoutes() {
2390
2392
  if (this.componentRoutes) return this.componentRoutes;
2391
2393
  const routes = buildComponentRoutes([
2392
2394
  ...this.controllerClasses
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var node_crypto = require('node:crypto');
4
- var themeResolvers = require('./theme-resolvers-CVm6n5-Q.cjs');
4
+ var themeResolvers = require('./theme-resolvers-DLgZL-uE.cjs');
5
5
 
6
6
  /** Whether an IPC message is one of MeoCord's own, rather than one of discord.js's. */ function isShardMessage(message) {
7
7
  return typeof message === 'object' && message !== null && 'meocord' in message;
@@ -34,7 +34,7 @@ var themeResolvers = require('./theme-resolvers-CVm6n5-Q.cjs');
34
34
  const own = clientOptions.shards ?? clientOptions.shardCount;
35
35
  if (sharding.mode === 'process') {
36
36
  if (own !== undefined) {
37
- throw new Error("sharding.mode 'process' starts one shard per process, so clientOptions.shards and shardCount must be unset.");
37
+ throw themeResolvers.refuse(new Error("meocord.config.ts: sharding.mode 'process' starts one shard per process, so clientOptions.shards and shardCount " + 'must be unset.'));
38
38
  }
39
39
  return clientOptions;
40
40
  }
@@ -54,7 +54,7 @@ var themeResolvers = require('./theme-resolvers-CVm6n5-Q.cjs');
54
54
  }
55
55
  const agrees = shards === 'auto' ? clientOptions.shards === 'auto' : clientOptions.shardCount === shards || Array.isArray(clientOptions.shards) && clientOptions.shards.length === shards;
56
56
  if (!agrees) {
57
- throw new Error(`sharding.shards (${String(shards)}) and clientOptions.shards/shardCount disagree; set the shards in one place.`);
57
+ throw themeResolvers.refuse(new Error(`meocord.config.ts: sharding.shards (${String(shards)}) and clientOptions.shards/shardCount disagree; set the ` + 'shards in one place.'));
58
58
  }
59
59
  return clientOptions;
60
60
  }
@@ -38,7 +38,7 @@ let configLoaded = false;
38
38
  try {
39
39
  // Called through a variable so a bundler does not try to resolve the path while building the
40
40
  // application; the file is only there once the build is done.
41
- const load = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('_shared/theme-resolvers-CVm6n5-Q.cjs', document.baseURI).href)));
41
+ const load = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('_shared/theme-resolvers-DLgZL-uE.cjs', document.baseURI).href)));
42
42
  const loaded = load(compiledPath);
43
43
  return loaded.default ?? loaded;
44
44
  } catch (error) {
@@ -708,7 +708,8 @@ const PLAIN = "give a plain object, such as { ...value }, or a row's toObject(),
708
708
  * A locale is resolved to the catalog that serves it: the exact locale, then another of the same language (`es-419` to
709
709
  * `es-ES`, `en-GB` to `en-US`), then the default. A message missing from that catalog is looked up the same way.
710
710
  *
711
- * @typeParam C - The default catalog, whose keys and params every locale is checked against.
711
+ * @typeParam C - The default catalog. Every locale's keys are checked against it, and so are the `{params}` of
712
+ * each message whose text the compiler can read; see {@link createTranslator}.
712
713
  *
713
714
  * @example
714
715
  * ```ts
@@ -754,8 +755,14 @@ const plurals = new Map();
754
755
  if (!rules) plurals.set(locale, rules = new Intl.PluralRules(locale));
755
756
  return message[rules.select(Number(count))] ?? message.other;
756
757
  }
758
+ /** A `{param}`: word characters between braces, the reading `Placeholders` follows at compile time. */ const PLACEHOLDER = /\{(\w+)}/g;
759
+ /** The `{param}` names of a message, in order, as translating fills them. */ function placeholderNames(message) {
760
+ return [
761
+ ...message.matchAll(PLACEHOLDER)
762
+ ].map(([, name])=>name);
763
+ }
757
764
  function interpolate(message, params) {
758
- return message.replace(/\{(\w+)}/g, (whole, name)=>Object.hasOwn(params, name) ? String(params[name]) : whole);
765
+ return message.replace(PLACEHOLDER, (whole, name)=>Object.hasOwn(params, name) ? String(params[name]) : whole);
759
766
  }
760
767
  class CatalogTranslator extends Translator {
761
768
  /** The catalogs that serve a locale, most specific first, ending with the default. */ [LOCALE_CHAIN](requested) {
@@ -823,6 +830,19 @@ class CatalogTranslator extends Translator {
823
830
  * Create it at module scope: command builders run when their class is decorated, before any container exists, and use
824
831
  * it for names and descriptions. Pass the same instance to `@MeoCord({ i18n })` to inject it as `Translator`.
825
832
  *
833
+ * Each locale is checked against the default catalog when the code compiles: every key must be one the default has,
834
+ * and a message may use only the `{params}` the default's message takes, and `{count}` in a plural's forms, in any
835
+ * order, all or some. The error names each mistake, such as:
836
+ *
837
+ * ```text
838
+ * id: ban.done takes no {usr}; the default is "Banned {user}."
839
+ * ```
840
+ *
841
+ * @remarks
842
+ * The compiler reads a message's params only from its text, which a catalog made with `defineCatalog`, written with
843
+ * `as const` or written inline keeps. A catalog from a plain variable or a JSON file types each message as `string`, so
844
+ * its params are checked by `expectCompleteCatalog` from `meocord/testing` instead, when a test runs.
845
+ *
826
846
  * @param options.default - The locale whose catalog is the reference and the last fallback.
827
847
  * @param options.locales - A catalog per discord.js `Locale`, including the default's.
828
848
  * @throws When the default locale has no catalog, or a key is not a Discord locale.
@@ -3811,6 +3831,7 @@ exports.ownHandlerList = ownHandlerList;
3811
3831
  exports.ownsAmbientTheme = ownsAmbientTheme;
3812
3832
  exports.parseSegment = parseSegment;
3813
3833
  exports.patternShape = patternShape;
3834
+ exports.placeholderNames = placeholderNames;
3814
3835
  exports.refuse = refuse;
3815
3836
  exports.registerClientTranslator = registerClientTranslator;
3816
3837
  exports.releaseAmbientTheme = releaseAmbientTheme;
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var themeResolvers = require('./theme-resolvers-CVm6n5-Q.cjs');
3
+ var themeResolvers = require('./theme-resolvers-DLgZL-uE.cjs');
4
4
  var enum_index = require('../enum/index.cjs');
5
5
  var discord_js = require('discord.js');
6
6
 
@@ -1,10 +1,10 @@
1
1
  'use strict';
2
2
 
3
- var themeResolvers = require('../_shared/theme-resolvers-CVm6n5-Q.cjs');
4
- var translateError = require('../_shared/translate-error-Bts5vGXt.cjs');
3
+ var themeResolvers = require('../_shared/theme-resolvers-DLgZL-uE.cjs');
4
+ var translateError = require('../_shared/translate-error-yLJa8q4Q.cjs');
5
5
  require('reflect-metadata');
6
6
  var node_crypto = require('node:crypto');
7
- var shardedCooldownStore = require('../_shared/sharded-cooldown-store-CBKsDtvc.cjs');
7
+ var shardedCooldownStore = require('../_shared/sharded-cooldown-store-ComEj8So.cjs');
8
8
  require('discord.js');
9
9
  require('path');
10
10
  require('../enum/index.cjs');
@@ -1,16 +1,16 @@
1
1
  'use strict';
2
2
 
3
3
  require('reflect-metadata');
4
- var providers = require('../_shared/providers-CSLRsJOZ.cjs');
4
+ var providers = require('../_shared/providers-CyQkBU8g.cjs');
5
5
  var inversify = require('inversify');
6
6
  var discord_js = require('discord.js');
7
- var themeResolvers = require('../_shared/theme-resolvers-CVm6n5-Q.cjs');
8
- require('../_shared/translate-error-Bts5vGXt.cjs');
7
+ var themeResolvers = require('../_shared/theme-resolvers-DLgZL-uE.cjs');
8
+ require('../_shared/translate-error-yLJa8q4Q.cjs');
9
9
  require('node:crypto');
10
- var shardedCooldownStore = require('../_shared/sharded-cooldown-store-CBKsDtvc.cjs');
10
+ var shardedCooldownStore = require('../_shared/sharded-cooldown-store-ComEj8So.cjs');
11
11
  var lodashEs = require('lodash-es');
12
12
  var enum_index = require('../enum/index.cjs');
13
- var shardContext = require('../_shared/shard-context-DV-BymqL.cjs');
13
+ var shardContext = require('../_shared/shard-context-BWzzLmaJ.cjs');
14
14
  var fs = require('fs');
15
15
  var path = require('path');
16
16
  require('node:async_hooks');
@@ -601,8 +601,6 @@ class MeoCordApp {
601
601
  this.attachEventHandlers();
602
602
  this.warnAboutMissingRequirements();
603
603
  this.noteGlobalStagesOnEvents();
604
- // Built now rather than on the first click, so overlapping patterns are reported at startup
605
- this.dispatcher.getComponentRoutes();
606
604
  const login = this.bot.login(this.discordToken);
607
605
  const stopped = new Promise((_, reject)=>{
608
606
  this.abortLogin = ()=>{
@@ -893,7 +891,8 @@ class MeoCordApp {
893
891
  this.close = ()=>this.closeClient();
894
892
  this.lifecycleUnits = lifecycleUnits ?? providers.classUnits(container, lifecycleClasses);
895
893
  this.bot = this.discordClient;
896
- // Built now, so a pattern that cannot be read or two that match the same messages stop the bot before login
894
+ // Built now, so a pattern that cannot be read or two that match the same messages or customIds stop the bot as it
895
+ // is created, before start() attaches anything
897
896
  this.dispatcher = new shardContext.Dispatcher({
898
897
  container,
899
898
  controllerClasses,
@@ -903,6 +902,7 @@ class MeoCordApp {
903
902
  botUserId: ()=>this.bot.user?.id,
904
903
  warnUnanswered
905
904
  });
905
+ this.dispatcher.getComponentRoutes();
906
906
  this.shutdownTimeout = typeof shutdownTimeout === 'number' && shutdownTimeout >= 0 ? shutdownTimeout : DEFAULT_SHUTDOWN_TIMEOUT_MS;
907
907
  }
908
908
  }
@@ -960,7 +960,7 @@ class MeoCordApp {
960
960
  }
961
961
  const running = currentPlatform();
962
962
  if (isSamePlatform(built, running)) return;
963
- throw new Error(`This build carries native addons compiled for ${describePlatform(built)}, but is running on ` + `${describePlatform(running)}. Compiled binaries only load on the platform they were built for. ` + 'Build on the same platform you deploy to -- for a container, run `meocord build` inside the image.');
963
+ throw themeResolvers.refuse(new Error(`${path.basename(distDir)}: this build carries native addons compiled for ${describePlatform(built)}, but is ` + `running on ${describePlatform(running)}. Compiled binaries only load on the platform they were built for. ` + 'Build on the same platform you deploy to -- for a container, run `meocord build` inside the image.'));
964
964
  }
965
965
 
966
966
  /** How long the manager waits for a shard to become ready before spawning the next. */ const SHARD_READY_TIMEOUT_MS = 30000;
@@ -1233,8 +1233,9 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1233
1233
  * @param target - The class `@MeoCord` decorates.
1234
1234
  * @returns The application, which `start()` logs in.
1235
1235
  * @throws Error when the class has no `@MeoCord`, when the built config is missing, when a provider cannot
1236
- * be bound, such as one for a token MeoCord binds itself, or when two handlers take one command, or two builder
1237
- * classes build one, naming both. In a built application it is logged first, as one line, so
1236
+ * be bound, such as one for a token MeoCord binds itself, when two handlers take one command, or two builder
1237
+ * classes build one, naming both, and for any other mistake it refuses as the app loads, such as two component
1238
+ * patterns that match the same customIds. In a built application it is logged first, as one line, so
1238
1239
  * `isExplainedError()` tells a caller not to log it again.
1239
1240
  */ static create(target) {
1240
1241
  try {
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  require('reflect-metadata');
4
- var themeResolvers = require('../_shared/theme-resolvers-CVm6n5-Q.cjs');
5
- var providers = require('../_shared/providers-CSLRsJOZ.cjs');
4
+ var themeResolvers = require('../_shared/theme-resolvers-DLgZL-uE.cjs');
5
+ var providers = require('../_shared/providers-CyQkBU8g.cjs');
6
6
  var enum_index = require('../enum/index.cjs');
7
7
  var inversify = require('inversify');
8
8
  require('discord.js');
@@ -2,11 +2,11 @@
2
2
 
3
3
  require('reflect-metadata');
4
4
  var inversify = require('inversify');
5
- var providers = require('../_shared/providers-CSLRsJOZ.cjs');
5
+ var providers = require('../_shared/providers-CyQkBU8g.cjs');
6
6
  var discord_js = require('discord.js');
7
7
  var enum_index = require('../enum/index.cjs');
8
- var themeResolvers = require('../_shared/theme-resolvers-CVm6n5-Q.cjs');
9
- var shardContext = require('../_shared/shard-context-DV-BymqL.cjs');
8
+ var themeResolvers = require('../_shared/theme-resolvers-DLgZL-uE.cjs');
9
+ var shardContext = require('../_shared/shard-context-BWzzLmaJ.cjs');
10
10
  var node_crypto = require('node:crypto');
11
11
  require('node:async_hooks');
12
12
  require('path');
@@ -19,7 +19,7 @@ require('module');
19
19
  require('node:fs');
20
20
  require('chalk');
21
21
  require('node:url');
22
- require('../_shared/translate-error-Bts5vGXt.cjs');
22
+ require('../_shared/translate-error-yLJa8q4Q.cjs');
23
23
  require('crypto');
24
24
  require('cli-table3');
25
25
 
@@ -957,10 +957,12 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
957
957
  * @remarks
958
958
  * Type guards such as `isButton()` run the real discord.js logic. An interaction gets an `id`, a `channelId` and a
959
959
  * `user` of its own unless given, and its `locale` is `'en-US'`; without a `guildId` it is a DM, and with one its
960
- * `member` is its user. Other data Discord always sends reads as Discord sends it, such as `false` for a flag and
961
- * `null` for what may be absent; what picks the handler, `commandName` or `customId`, is the test's to give. Replies
962
- * follow Discord's order, so a second `reply()` rejects, and {@link getResponse} reports what `respond()` sent. Every
963
- * method is a mock function, and one that returns a promise in discord.js resolves.
960
+ * `member` is its user: for a `user` given, when the test gives the `guild`, the guild's cached member for that user,
961
+ * so a message and an interaction from one user in that server share it. Other data Discord always sends reads as
962
+ * Discord sends it, such as `false` for a flag and `null` for what may be absent; what picks the handler, `commandName`
963
+ * or `customId`, is the test's to give. Replies follow Discord's order, so a second `reply()` rejects, and
964
+ * {@link getResponse} reports what `respond()` sent. Every method is a mock function, and one that returns a promise in
965
+ * discord.js resolves.
964
966
  *
965
967
  * @param Class - The discord.js class to mock.
966
968
  * @param props - Values for properties the class declares `readonly`; see {@link MockProps}.
@@ -1119,7 +1121,8 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
1119
1121
  const unset = (key)=>!Object.prototype.hasOwnProperty.call(instance, key);
1120
1122
  const generatedId = unset('id') ? instance.id = nextSnowflake() : undefined;
1121
1123
  defineCreatedTime(instance, generatedId);
1122
- if (unset('user')) instance.user = mockUser();
1124
+ const userGiven = !unset('user');
1125
+ if (!userGiven) instance.user = mockUser();
1123
1126
  if (unset('channelId')) instance.channelId = nextSnowflake();
1124
1127
  if (unset('guildId')) {
1125
1128
  instance.guildId = null;
@@ -1133,7 +1136,7 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
1133
1136
  let member;
1134
1137
  if (unset('member')) {
1135
1138
  Object.defineProperty(instance, 'member', {
1136
- get: ()=>own('guildId') ? member ?? (member = memberOf(own('user'))) : null,
1139
+ get: ()=>own('guildId') ? member ?? (member = memberFor(own('guild'), own('user'), userGiven)) : null,
1137
1140
  set: (value)=>Object.defineProperty(instance, 'member', {
1138
1141
  value,
1139
1142
  writable: true,
@@ -1414,10 +1417,28 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
1414
1417
  Object.assign(instance, props);
1415
1418
  return stubDeep(instance);
1416
1419
  }
1417
- /** A member of a server, as the given user. */ function memberOf(user) {
1418
- return stubDeep(Object.assign(Object.create(discord_js.GuildMember.prototype), {
1419
- user
1420
- }));
1420
+ /** A member of `guild` as the user with `id`: its id, user and guild own values, as the gateway delivers a member. */ function memberIn(guild, id, user) {
1421
+ const own = {
1422
+ id: {
1423
+ value: id
1424
+ },
1425
+ user: {
1426
+ value: user
1427
+ },
1428
+ ...guild ? {
1429
+ guild: {
1430
+ value: guild
1431
+ }
1432
+ } : {}
1433
+ };
1434
+ return stubDeep(Object.defineProperties(Object.create(discord_js.GuildMember.prototype), own));
1435
+ }
1436
+ /**
1437
+ * The member a server has for `user`. A user the test gave is looked up in the guild's member cache, and cached
1438
+ * there when missing, as the gateway resolves a member, so every mock from that user in that server shares it.
1439
+ */ function memberFor(guild, user, given) {
1440
+ const make = ()=>memberIn(guild, user.id, user);
1441
+ return given ? cached(cacheOf(guild?.members), user.id, make) : make();
1421
1442
  }
1422
1443
  /** The guild a mock message carries: a guild with the same stubbed managers as createMockGuild. */ function createMockGuildForMessage() {
1423
1444
  const guild = Object.create(discord_js.Guild.prototype);
@@ -1469,20 +1490,7 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
1469
1490
  ])),
1470
1491
  members: guildCaches ? new discord_js.Collection(users.map((id)=>[
1471
1492
  id,
1472
- cached(guildCaches.members, id, ()=>{
1473
- const user = cached(userCache, id, ()=>mockUser(id));
1474
- return stubDeep(Object.defineProperties(Object.create(discord_js.GuildMember.prototype), {
1475
- id: {
1476
- value: id
1477
- },
1478
- user: {
1479
- value: user
1480
- },
1481
- guild: {
1482
- value: guild
1483
- }
1484
- }));
1485
- })
1493
+ cached(guildCaches.members, id, ()=>memberIn(guild, id, cached(userCache, id, ()=>mockUser(id))))
1486
1494
  ])) : null,
1487
1495
  roles: new discord_js.Collection(guildCaches ? roles.map((id)=>[
1488
1496
  id,
@@ -1583,9 +1591,10 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
1583
1591
  *
1584
1592
  * @remarks
1585
1593
  * `delete()`, `edit()`, `reply()`, `react()`, `pin()` and `unpin()` throw once the message is deleted, and `edit()`
1586
- * and `reply()` resolve to a new mock message. Without overrides the message is empty, in a server, with its author
1587
- * as its `member`; with `guild: null` it is a direct message. The users, roles and channels the content mentions are
1588
- * cached as the gateway delivers them.
1594
+ * and `reply()` resolve to a new mock message. Without overrides the message is empty, in a server, from a new
1595
+ * person who is its `member`; with `guild: null` it is a direct message. Give `author` to send several messages as
1596
+ * one user, such as to reach a per-user cooldown. An author given, and the users, roles and channels the content
1597
+ * mentions, are cached as the gateway delivers them.
1589
1598
  *
1590
1599
  * @param overrides - The message's content, components and the rest; see {@link MockMessageOverrides}.
1591
1600
  *
@@ -1612,7 +1621,7 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
1612
1621
  const stubs = new Map();
1613
1622
  instance.deleted = false;
1614
1623
  // Constructor-assigned — set as prototype-based stubs; a user rather than a bot, as dispatch handles only those
1615
- instance.author = mockUser();
1624
+ instance.author = overrides.author ?? mockUser();
1616
1625
  // Getters on the prototype — the proxy sees them as functions and returns
1617
1626
  // a mock fn, which is wrong. Pre-initialize as own properties to shadow
1618
1627
  // the prototype getters.
@@ -1628,9 +1637,12 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
1628
1637
  value: guild,
1629
1638
  writable: true
1630
1639
  });
1631
- // The author as a member of the message's server; a direct message has none
1640
+ // The author as a member of the message's server; a direct message has none. A given author's member is the one
1641
+ // the server caches, as the gateway resolves it, so every message from that author has the same member
1642
+ const author = overrides.author;
1643
+ const member = guild ? memberFor(guild, instance.author, author !== undefined) : null;
1632
1644
  Object.defineProperty(instance, 'member', {
1633
- value: guild ? memberOf(instance.author) : null,
1645
+ value: member,
1634
1646
  writable: true
1635
1647
  });
1636
1648
  // In a server's text channel, the ids matching the objects; with no guild, a DM
@@ -1647,7 +1659,12 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
1647
1659
  writable: true
1648
1660
  });
1649
1661
  const userCache = cacheOf(client.users);
1650
- for (const user of overrides.users ?? [])userCache?.set(user.id, user);
1662
+ for (const user of [
1663
+ ...overrides.users ?? [],
1664
+ ...author ? [
1665
+ author
1666
+ ] : []
1667
+ ])userCache?.set(user.id, user);
1651
1668
  // MessageMentions — constructor-assigned; what the content mentions, cached as the gateway delivers it
1652
1669
  instance.mentions = mentionsOf(overrides.content, client, guild);
1653
1670
  // Data a message always has, real rather than stubbed, so code reading it sees an empty message
@@ -2855,16 +2872,36 @@ function at(catalog, key) {
2855
2872
  }
2856
2873
  const MEOCORD_KEYS = new Set(leaves(themeResolvers.MEOCORD_MESSAGES).map(({ key })=>key));
2857
2874
  const isMeoCordKey = (key)=>key.startsWith('meocord.');
2875
+ /** The `{param}` names of a message, every form of a plural's, as translating fills them. */ function placeholders(message) {
2876
+ const texts = typeof message === 'string' ? [
2877
+ message
2878
+ ] : Object.values(message);
2879
+ return new Set(texts.flatMap((text)=>typeof text === 'string' ? themeResolvers.placeholderNames(text) : []));
2880
+ }
2881
+ /** A message as a report shows it: the text, or a plural's `other` form. */ const shown = (message)=>typeof message === 'string' ? message : String(message.other);
2882
+ /** The `{params}` a translation uses that the message it translates doesn't take; a plural's `{count}` is taken. */ function strayParams(translation, original) {
2883
+ const taken = placeholders(original);
2884
+ if (isPlural(original)) taken.add('count');
2885
+ return [
2886
+ ...placeholders(translation)
2887
+ ].filter((name)=>!taken.has(name));
2888
+ }
2858
2889
  /**
2859
2890
  * Checks that every locale translates every message, and throws listing each gap, locale by locale.
2860
2891
  *
2861
2892
  * Use it in a test for a team that wants no fallback in production. It reports the messages a locale lacks, the
2862
- * messages the default catalog doesn't have, and the plural forms a language needs but a plural lacks, such as
2863
- * `few` for Russian.
2893
+ * messages the default catalog doesn't have, the plural forms a language needs but a plural lacks, such as `few` for
2894
+ * Russian, and each `{param}` a translation uses that the message it translates doesn't take. It reads the catalogs'
2895
+ * own strings, so it checks the params of a catalog from a plain variable or a JSON file too, which the compiler
2896
+ * can't.
2864
2897
  *
2865
2898
  * @remarks
2866
- * MeoCord's own texts, a catalog's `meocord` group, fall back to English by design, so only a key MeoCord lacks is
2867
- * reported. With `meocord: true`, every locale other than an English one must translate each of them as well.
2899
+ * MeoCord's own texts, a catalog's `meocord` group, fall back to English by design, so only a key MeoCord lacks, and
2900
+ * a `{param}` MeoCord's English doesn't take, is reported. With `meocord: true`, every locale other than an English
2901
+ * one must translate each of them as well.
2902
+ *
2903
+ * A translation may leave a param out, as a language can say it without the name, so a missing param isn't
2904
+ * reported.
2868
2905
  *
2869
2906
  * @param translator - A translator made by `createTranslator`.
2870
2907
  * @param options.meocord - Whether every locale that is not English must translate each of MeoCord's own texts.
@@ -2893,7 +2930,8 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
2893
2930
  */ function expectCompleteCatalog(translator, options = {}) {
2894
2931
  const catalogs = translator[themeResolvers.CATALOGS];
2895
2932
  if (!catalogs) throw new Error('expectCompleteCatalog takes a translator made by createTranslator.');
2896
- const reference = leaves(catalogs[translator.defaultLocale] ?? {}).filter(({ key })=>!isMeoCordKey(key));
2933
+ const defaultCatalog = catalogs[translator.defaultLocale] ?? {};
2934
+ const reference = leaves(defaultCatalog).filter(({ key })=>!isMeoCordKey(key));
2897
2935
  const referenceKeys = new Set(reference.map(({ key })=>key));
2898
2936
  const gaps = [];
2899
2937
  for (const locale of translator.locales){
@@ -2904,10 +2942,15 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
2904
2942
  const message = at(catalog, key);
2905
2943
  if (message === undefined) {
2906
2944
  problems.push(`missing ${key}`);
2907
- } else if (plural && isPlural(message)) {
2945
+ continue;
2946
+ }
2947
+ if (plural && isPlural(message)) {
2908
2948
  const missing = categories.filter((category)=>typeof message[category] !== 'string');
2909
2949
  if (missing.length > 0) problems.push(`${key} lacks ${missing.join(', ')}`);
2910
2950
  }
2951
+ const original = at(defaultCatalog, key);
2952
+ const stray = locale === translator.defaultLocale ? [] : strayParams(message, original);
2953
+ for (const name of stray)problems.push(`${key} takes no {${name}}; the default is "${shown(original)}"`);
2911
2954
  }
2912
2955
  if (options.meocord && !locale.startsWith('en-')) {
2913
2956
  for (const key of MEOCORD_KEYS)if (at(catalog, key) === undefined) problems.push(`missing ${key}`);
@@ -2915,6 +2958,11 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
2915
2958
  for (const { key } of leaves(catalog)){
2916
2959
  if (isMeoCordKey(key)) {
2917
2960
  if (!MEOCORD_KEYS.has(key)) problems.push(`${key} is not one of MeoCord's texts`);
2961
+ else {
2962
+ const english = at(themeResolvers.MEOCORD_MESSAGES, key);
2963
+ const stray = strayParams(at(catalog, key), english);
2964
+ for (const name of stray)problems.push(`${key} takes no {${name}}: MeoCord's English is "${shown(english)}"`);
2965
+ }
2918
2966
  } else if (!referenceKeys.has(key)) problems.push(`${key} is not in the default catalog`);
2919
2967
  }
2920
2968
  if (problems.length > 0) gaps.push(` ${locale}: ${problems.join('; ')}`);
package/dist/cli.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "meocordVersion": "4.1.0-beta.8",
3
+ "meocordVersion": "4.1.0-beta.9",
4
4
  "name": "meocord",
5
5
  "description": "CLI for managing the MeoCord application",
6
6
  "usage": "[options] [command]",
@@ -13,6 +13,8 @@ import '../core/theme-scope.js';
13
13
  import '../common/translator.js';
14
14
  import 'node:crypto';
15
15
  import '../decorator/controller.decorator.js';
16
+ import 'node:fs';
17
+ import 'node:url';
16
18
  import '../core/theme-resolvers.js';
17
19
  import { ServiceGeneratorHelper } from './helper/service-generator.helper.js';
18
20
  import { GuardGeneratorHelper } from './helper/guard-generator.helper.js';
@@ -9,6 +9,8 @@ import '../../core/theme-scope.js';
9
9
  import '../../common/translator.js';
10
10
  import 'node:crypto';
11
11
  import '../../decorator/controller.decorator.js';
12
+ import 'node:fs';
13
+ import 'node:url';
12
14
  import '../../core/theme-resolvers.js';
13
15
  import { validateAndFormatName, assertFilesAbsent, buildTemplate, createDirectoryIfNotExists, generateFile } from '../../util/generator-cli.util.js';
14
16
 
@@ -9,6 +9,8 @@ import '../../core/theme-scope.js';
9
9
  import '../../common/translator.js';
10
10
  import 'node:crypto';
11
11
  import '../../decorator/controller.decorator.js';
12
+ import 'node:fs';
13
+ import 'node:url';
12
14
  import '../../core/theme-resolvers.js';
13
15
  import { validateAndFormatName, assertFilesAbsent, buildTemplate, createDirectoryIfNotExists, generateFile } from '../../util/generator-cli.util.js';
14
16
 
@@ -9,6 +9,8 @@ import '../../core/theme-scope.js';
9
9
  import '../../common/translator.js';
10
10
  import 'node:crypto';
11
11
  import '../../decorator/controller.decorator.js';
12
+ import 'node:fs';
13
+ import 'node:url';
12
14
  import '../../core/theme-resolvers.js';
13
15
  import { validateAndFormatName, assertFilesAbsent, buildTemplate, createDirectoryIfNotExists, generateFile } from '../../util/generator-cli.util.js';
14
16