chapterjs 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/cli/build.js +9 -10
  2. package/dist/cli/dev.js +17 -16
  3. package/dist/cli/project.d.ts +38 -25
  4. package/dist/cli/project.js +199 -134
  5. package/dist/commands/command.d.ts +25 -11
  6. package/dist/commands/command.js +5 -5
  7. package/dist/commands/{convention.d.ts → declaration.d.ts} +24 -11
  8. package/dist/commands/{convention.js → declaration.js} +61 -33
  9. package/dist/commands/tree.d.ts +8 -4
  10. package/dist/commands/tree.js +16 -6
  11. package/dist/components/button.d.ts +13 -13
  12. package/dist/components/button.js +9 -11
  13. package/dist/components/component.d.ts +1 -1
  14. package/dist/components/component.js +1 -1
  15. package/dist/components/custom-id.d.ts +4 -4
  16. package/dist/components/custom-id.js +12 -12
  17. package/dist/components/declaration.d.ts +42 -0
  18. package/dist/components/{convention.js → declaration.js} +76 -73
  19. package/dist/components/declared.d.ts +29 -0
  20. package/dist/components/declared.js +49 -0
  21. package/dist/components/embed.d.ts +13 -12
  22. package/dist/components/embed.js +6 -6
  23. package/dist/components/instance.d.ts +3 -3
  24. package/dist/components/instance.js +2 -2
  25. package/dist/components/modal.d.ts +11 -10
  26. package/dist/components/modal.js +7 -8
  27. package/dist/components/router.d.ts +3 -2
  28. package/dist/components/router.js +14 -19
  29. package/dist/components/select.d.ts +14 -12
  30. package/dist/components/select.js +4 -4
  31. package/dist/events/declaration.d.ts +16 -0
  32. package/dist/events/declaration.js +80 -0
  33. package/dist/events/event.d.ts +42 -5
  34. package/dist/events/event.js +25 -11
  35. package/dist/events/registry.d.ts +1 -10
  36. package/dist/events/router.d.ts +3 -2
  37. package/dist/index.d.ts +11 -9
  38. package/dist/index.js +2 -0
  39. package/dist/loader/generated.d.ts +5 -32
  40. package/dist/loader/generated.js +21 -55
  41. package/dist/loader/loader.d.ts +57 -44
  42. package/dist/loader/loader.js +73 -89
  43. package/dist/main.d.ts +0 -1
  44. package/dist/main.js +2 -3
  45. package/dist/messages/commands.d.ts +3 -3
  46. package/dist/messages/commands.js +29 -10
  47. package/dist/messages/declaration.d.ts +89 -0
  48. package/dist/messages/declaration.js +455 -0
  49. package/dist/messages/messages.d.ts +65 -43
  50. package/dist/messages/messages.js +11 -10
  51. package/dist/messages/phrases.js +1 -1
  52. package/dist/messages/translate.d.ts +4 -4
  53. package/dist/messages/translate.js +4 -4
  54. package/dist/presence/declaration.d.ts +19 -0
  55. package/dist/presence/{convention.js → declaration.js} +13 -28
  56. package/dist/presence/presence.d.ts +7 -7
  57. package/dist/presence/presence.js +5 -5
  58. package/dist/structures/context.d.ts +1 -1
  59. package/dist/structures/interaction.d.ts +2 -2
  60. package/dist/structures/interaction.js +3 -3
  61. package/dist/structures/payload.d.ts +4 -4
  62. package/dist/structures/payload.js +4 -4
  63. package/dist/tasks/declaration.d.ts +16 -0
  64. package/dist/tasks/{convention.js → declaration.js} +14 -36
  65. package/dist/tasks/scheduler.d.ts +3 -1
  66. package/dist/tasks/scheduler.js +4 -4
  67. package/dist/tasks/task.d.ts +8 -9
  68. package/dist/tasks/task.js +6 -7
  69. package/package.json +1 -1
  70. package/dist/components/convention.d.ts +0 -35
  71. package/dist/components/file.d.ts +0 -30
  72. package/dist/components/file.js +0 -52
  73. package/dist/events/convention.d.ts +0 -25
  74. package/dist/events/convention.js +0 -97
  75. package/dist/events/types.d.ts +0 -7
  76. package/dist/events/types.js +0 -48
  77. package/dist/messages/convention.d.ts +0 -89
  78. package/dist/messages/convention.js +0 -347
  79. package/dist/presence/convention.d.ts +0 -26
  80. package/dist/tasks/convention.d.ts +0 -26
@@ -0,0 +1,455 @@
1
+ // What a file declares with `language()`: the texts of the bot in one
2
+ // language. What a file declares is checked when it loads, and the
3
+ // languages are checked against each other once loaded: every one has the
4
+ // texts of the default language, with the same placeholders, so a missing
5
+ // text is explained before anyone asks for it.
6
+ import { readFile } from 'node:fs/promises';
7
+ import { commandName } from '../commands/tree.js';
8
+ import { listSources, siteName, } from '../loader/loader.js';
9
+ import { isLanguageDeclaration } from './messages.js';
10
+ import { FRAMEWORK, FRAMEWORK_KEYS } from './phrases.js';
11
+ import { compile, compilePlural, LOCALES, PLURAL_FORMS, } from './translate.js';
12
+ const fail = message => {
13
+ throw new TypeError(message);
14
+ };
15
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
17
+ const KEYS = ['locale', 'default', 'texts', 'commands', 'framework'];
18
+ const EXAMPLE = `language({ locale: 'en-US', texts: { pong: 'Pong!' } })`;
19
+ /** Placeholders, for a message: `{name}, {count}`, or `none`. */
20
+ const showParams = (names) => names.length === 0 ? 'none' : names.map(name => `{${name}}`).join(', ');
21
+ /** Whether two templates take the same placeholders. */
22
+ const samePlaceholders = (a, b) => [...a.params].sort().join('\0') === [...b.params].sort().join('\0');
23
+ /** One form of a text, cut around its placeholders, each one checked. */
24
+ function readForm(what, text, example) {
25
+ if (typeof text !== 'string' || text === '') {
26
+ fail(`${what} must be a text: ${example}`);
27
+ }
28
+ const template = compile(text);
29
+ for (const name of template.params) {
30
+ if (!NAME.test(name)) {
31
+ fail(`${what} has a placeholder "{${name}}": a placeholder is a name made of letters, digits and _, like {name}.`);
32
+ }
33
+ }
34
+ return template;
35
+ }
36
+ /**
37
+ * A text of a language file: a string, or a plural (one form per
38
+ * quantity, `other` at least), with an error saying what to write.
39
+ */
40
+ function readText(key, text, kind = 'message') {
41
+ const what = `The ${kind} "${key}"`;
42
+ if (isRecord(text)) {
43
+ const forms = new Map();
44
+ for (const [form, one] of Object.entries(text)) {
45
+ if (!PLURAL_FORMS.includes(form)) {
46
+ fail(`"${form}" is not a form of the plural "${key}". A plural has: ${PLURAL_FORMS.join(', ')} (other at least): ${key}: { one: '{count} member', other: '{count} members' }`);
47
+ }
48
+ forms.set(form, readForm(`The form "${form}" of the plural "${key}"`, one, `${form}: '{count} members'`));
49
+ }
50
+ if (!forms.has('other')) {
51
+ fail(`The plural "${key}" needs an "other" form, used when no other form fits: ${key}: { one: '{count} member', other: '{count} members' }`);
52
+ }
53
+ return compilePlural(forms);
54
+ }
55
+ return readForm(what, text, `${key}: 'Hello {name}!'${kind === 'message' ? `, or a plural: ${key}: { one: '{count} member', other: '{count} members' }` : ''}`);
56
+ }
57
+ /** The language a declaration says it is, or an error saying which ones exist. */
58
+ function readLocale(locale) {
59
+ if (locale === undefined) {
60
+ fail(`This language has no "locale": the language its texts are in, like ${EXAMPLE}. It can be: ${[...LOCALES].join(', ')}.`);
61
+ }
62
+ if (typeof locale !== 'string' || !LOCALES.has(locale)) {
63
+ fail(`${JSON.stringify(locale) ?? typeof locale} is not a language Discord knows. "locale" can be: ${[...LOCALES].join(', ')}.`);
64
+ }
65
+ return locale;
66
+ }
67
+ /**
68
+ * A language, declared anywhere in `src/` with `language()`: `locale` says
69
+ * which one, `texts` what the bot says in it.
70
+ */
71
+ export const languageDeclaration = {
72
+ one: 'language',
73
+ many: 'languages',
74
+ is: isLanguageDeclaration,
75
+ list: true,
76
+ read(value) {
77
+ const config = value.config;
78
+ if (!isRecord(config)) {
79
+ return fail(`language() needs an object: ${EXAMPLE}`);
80
+ }
81
+ const locale = readLocale(config.locale);
82
+ for (const key of Object.keys(config)) {
83
+ if (!KEYS.includes(key)) {
84
+ fail(`"${key}" is not something a language has. It can have: ${KEYS.join(', ')}.`);
85
+ }
86
+ }
87
+ if (config.default !== undefined && typeof config.default !== 'boolean') {
88
+ fail(`"default" is true or false, got ${JSON.stringify(config.default)}.`);
89
+ }
90
+ if (!isRecord(config.texts)) {
91
+ fail(`"texts" is an object, one text per key: texts: { pong: 'Pong!', welcome: 'Welcome {name}!' }`);
92
+ }
93
+ const texts = new Map();
94
+ for (const [key, text] of Object.entries(config.texts)) {
95
+ texts.set(key, readText(key, text));
96
+ }
97
+ const framework = new Map();
98
+ if (config.framework !== undefined) {
99
+ if (!isRecord(config.framework)) {
100
+ fail(`"framework" is an object, one phrase per key: framework: { guildOnly: '...' }. It can have: ${FRAMEWORK_KEYS.join(', ')}.`);
101
+ }
102
+ for (const [key, text] of Object.entries(config.framework)) {
103
+ const expected = FRAMEWORK.get(key);
104
+ if (!expected) {
105
+ fail(`"${key}" is not a phrase of the framework. It can be: ${FRAMEWORK_KEYS.join(', ')}.`);
106
+ }
107
+ const template = readText(key, text, 'phrase');
108
+ if (!samePlaceholders(template, expected)) {
109
+ fail(`The phrase "${key}" does not have the placeholders of the framework: it has ${showParams(template.params)}, the framework has ${showParams(expected.params)}.`);
110
+ }
111
+ framework.set(key, template);
112
+ }
113
+ }
114
+ if (config.commands !== undefined) {
115
+ if (!isRecord(config.commands)) {
116
+ fail(`"commands" is an object whose keys are the paths of your command files: commands: { ping: { description: '...' }, 'mod/ban': { name: '...' } }`);
117
+ }
118
+ for (const [name, translation] of Object.entries(config.commands)) {
119
+ if (!/^[^\s]+(?: [^\s]+){0,2}$/.test(name)) {
120
+ fail(`"${name}" in "commands" is not the name of a command: write it as the command declares it, like 'ping' or 'mod ban'.`);
121
+ }
122
+ if (!isRecord(translation)) {
123
+ fail(`The texts of the command ${commandName(name.split(' '))} must be an object like { name: '...', description: '...' }.`);
124
+ }
125
+ }
126
+ }
127
+ return {
128
+ locale,
129
+ isDefault: config.default === true,
130
+ texts,
131
+ framework,
132
+ commands: config.commands,
133
+ };
134
+ },
135
+ };
136
+ /**
137
+ * Puts the languages together: one default, which `t` is typed from and
138
+ * gives a text to the languages that do not have it, and every other
139
+ * language with texts the default has, the same placeholders included. A
140
+ * language that does not match is left out and reported on its file: the
141
+ * others still work. `null` when there is no language at all.
142
+ */
143
+ export function assembleMessages(entries) {
144
+ if (entries.length === 0)
145
+ return { messages: null, failed: [] };
146
+ const failed = [];
147
+ // A language may be declared in several places (the texts of one feature
148
+ // next to it): what says default: true decides for the whole language.
149
+ const byLocale = new Map();
150
+ for (const entry of entries) {
151
+ const list = byLocale.get(entry.language.locale) ?? [];
152
+ list.push(entry);
153
+ byLocale.set(entry.language.locale, list);
154
+ }
155
+ const defaults = [...byLocale.keys()].filter(locale => byLocale.get(locale).some(({ language }) => language.isDefault));
156
+ let defaultLocale;
157
+ if (byLocale.size === 1) {
158
+ defaultLocale = entries[0].language.locale;
159
+ }
160
+ else if (defaults.length === 1) {
161
+ defaultLocale = defaults[0];
162
+ }
163
+ else if (defaults.length === 0) {
164
+ // No default: nothing can be assembled.
165
+ return {
166
+ messages: null,
167
+ failed: entries.map(({ file, export: exported }) => ({
168
+ file,
169
+ export: exported,
170
+ error: new TypeError(`None of the ${byLocale.size} languages is the default one, used when the language of a person or of a server is not there. Add default: true in one of them.`),
171
+ })),
172
+ };
173
+ }
174
+ else {
175
+ const saying = entries.filter(({ language }) => language.isDefault);
176
+ return {
177
+ messages: null,
178
+ failed: saying.map(entry => ({
179
+ file: entry.file,
180
+ export: entry.export,
181
+ error: new TypeError(`${defaults.length} languages say default: true (${saying.map(one => `${one.language.locale} in ${siteName(one)}`).join(', ')}): only one can be the default.`),
182
+ })),
183
+ };
184
+ }
185
+ /** The texts of one language, from every place it is declared. */
186
+ const merge = (locale, what, kind, onConflict) => {
187
+ const merged = new Map();
188
+ const from = new Map();
189
+ for (const entry of byLocale.get(locale)) {
190
+ for (const [key, template] of entry.language[what]) {
191
+ const first = from.get(key);
192
+ if (first) {
193
+ onConflict(entry, new TypeError(`The ${kind} "${key}" is already in ${siteName(first)}: the ${locale} language has it twice.`));
194
+ continue;
195
+ }
196
+ from.set(key, entry);
197
+ merged.set(key, template);
198
+ }
199
+ }
200
+ return merged;
201
+ };
202
+ const report = (entry, error) => {
203
+ failed.push({ file: entry.file, export: entry.export, error });
204
+ };
205
+ const referenceTexts = merge(defaultLocale, 'texts', 'message', report);
206
+ const referenceSites = byLocale.get(defaultLocale);
207
+ const name = `the default language (${referenceSites.map(siteName).join(', ')})`;
208
+ // A text the default language does not have is taken from the language
209
+ // that has it (en-US first, then the others in order): the texts of a
210
+ // module written in other languages than the project's default still
211
+ // work, and `t` offers them. Where each such text comes from decides
212
+ // the placeholders every language must then follow.
213
+ const fallbackOrder = [...byLocale.keys()]
214
+ .filter(locale => locale !== defaultLocale)
215
+ .sort((a, b) => (a === 'en-US' ? -1 : b === 'en-US' ? 1 : a < b ? -1 : 1));
216
+ const fallbackFrom = new Map();
217
+ for (const locale of fallbackOrder) {
218
+ for (const entry of byLocale.get(locale)) {
219
+ for (const [key, template] of entry.language.texts) {
220
+ if (referenceTexts.has(key))
221
+ continue;
222
+ referenceTexts.set(key, template);
223
+ fallbackFrom.set(key, entry);
224
+ }
225
+ }
226
+ }
227
+ const referenceName = (key) => {
228
+ const from = fallbackFrom.get(key);
229
+ return from
230
+ ? `${from.language.locale} (${siteName(from)}), the language this message comes from`
231
+ : name;
232
+ };
233
+ const locales = new Map();
234
+ const framework = new Map();
235
+ const commands = new Map();
236
+ const files = new Map();
237
+ for (const [locale, group] of byLocale) {
238
+ // A text a language does not have is taken from the default one (or
239
+ // from the language the text comes from): every language that has it
240
+ // must take the same placeholders.
241
+ const kept = [];
242
+ for (const entry of group) {
243
+ try {
244
+ if (locale !== defaultLocale) {
245
+ for (const [key, template] of entry.language.texts) {
246
+ const expected = referenceTexts.get(key);
247
+ if (expected !== template &&
248
+ !samePlaceholders(template, expected)) {
249
+ fail(`The message "${key}" does not have the placeholders of ${referenceName(key)}: it has ${showParams([...template.params].sort())}, that one has ${showParams([...expected.params].sort())}.`);
250
+ }
251
+ }
252
+ }
253
+ kept.push(entry);
254
+ }
255
+ catch (error) {
256
+ report(entry, error);
257
+ }
258
+ }
259
+ if (kept.length === 0)
260
+ continue;
261
+ const texts = locale === defaultLocale
262
+ ? referenceTexts
263
+ : new Map(kept.flatMap(({ language }) => [...language.texts]));
264
+ if (locale !== defaultLocale) {
265
+ // The same message twice in a language that is not the default.
266
+ const from = new Map();
267
+ for (const entry of kept) {
268
+ for (const key of entry.language.texts.keys()) {
269
+ const first = from.get(key);
270
+ if (first) {
271
+ report(entry, new TypeError(`The message "${key}" is already in ${siteName(first)}: the ${locale} language has it twice.`));
272
+ }
273
+ else
274
+ from.set(key, entry);
275
+ }
276
+ }
277
+ }
278
+ locales.set(locale, texts);
279
+ const phrases = merge(locale, 'framework', 'phrase', report);
280
+ if (phrases.size > 0)
281
+ framework.set(locale, phrases);
282
+ files.set(locale, kept[0].file);
283
+ const sections = kept.filter(({ language }) => language.commands);
284
+ if (sections.length > 0) {
285
+ const translated = {};
286
+ const from = new Map();
287
+ for (const entry of sections) {
288
+ for (const [command, section] of Object.entries(entry.language.commands)) {
289
+ const first = from.get(command);
290
+ if (first) {
291
+ report(entry, new TypeError(`The command "${command}" is already translated in ${siteName(first)}: the ${locale} language translates it twice.`));
292
+ continue;
293
+ }
294
+ from.set(command, entry);
295
+ translated[command] = section;
296
+ }
297
+ }
298
+ commands.set(locale, translated);
299
+ }
300
+ }
301
+ return {
302
+ messages: { default: defaultLocale, locales, framework, commands, files },
303
+ failed,
304
+ };
305
+ }
306
+ /**
307
+ * The augmentation that types `t` from the files themselves: the editor
308
+ * reads every declaration of the default language directly, so the keys
309
+ * and placeholders follow every keystroke; the other languages take the
310
+ * texts they do not have from it. `languages` is every language declared,
311
+ * `defaultLocale` the one `t` is typed from (the first one when it is not
312
+ * known yet: `sync`, which runs nothing). Nothing when the project has no
313
+ * language.
314
+ */
315
+ export function messagesDeclarations(languages, defaultLocale) {
316
+ const bySite = (a, b) => a.file === b.file
317
+ ? a.export < b.export
318
+ ? -1
319
+ : 1
320
+ : a.file < b.file
321
+ ? -1
322
+ : 1;
323
+ const sorted = [...languages].sort(bySite);
324
+ const first = sorted.find(({ locale }) => locale === defaultLocale) ?? sorted[0];
325
+ if (!first)
326
+ return '';
327
+ // Every declaration, the default language first, then en-US, then the
328
+ // others: `t` knows the texts of each, the first one winning for a key
329
+ // several have, as the bot does.
330
+ const rank = (locale) => locale === first.locale ? 0 : locale === 'en-US' ? 1 : 2;
331
+ const ordered = [...sorted].sort((a, b) => rank(a.locale) - rank(b.locale) ||
332
+ (a.locale < b.locale ? -1 : a.locale > b.locale ? 1 : 0) ||
333
+ bySite(a, b));
334
+ const parts = ordered.map(one => {
335
+ const path = one.file.replace(/^src\//, '').replace(/\.[^./]+$/, '');
336
+ const member = one.export === 'default'
337
+ ? '.default'
338
+ : /^[A-Za-z_$][\w$]*$/.test(one.export)
339
+ ? `.${one.export}`
340
+ : `[${JSON.stringify(one.export)}]`;
341
+ return `MessagesOf<typeof import('../../src/${path}')${member}>`;
342
+ });
343
+ const where = ordered
344
+ .filter(({ locale }) => locale === first.locale)
345
+ .map(siteName)
346
+ .join(', ');
347
+ const locales = [...new Set(sorted.map(({ locale }) => locale))]
348
+ .map(locale => ` ${JSON.stringify(locale)}: true;`)
349
+ .sort()
350
+ .join('\n');
351
+ return `import type { MergedMessages, MessagesOf } from '#chapterjs';
352
+
353
+ declare module 'chapterjs' {
354
+ /** The texts of the bot, as ${where} ${where.includes(',') ? 'write' : 'writes'} them (the ${first.locale} language), then what the other languages add: what \`t\` accepts. */
355
+ export interface ProjectMessages
356
+ extends MergedMessages<
357
+ [
358
+ ${parts.join(',\n ')},
359
+ ]
360
+ > {}
361
+ /** The languages of the project: what \`t.in()\` offers. */
362
+ export interface ProjectLocales {
363
+ ${locales}
364
+ }
365
+ }
366
+ `;
367
+ }
368
+ /**
369
+ * The augmentation that underlines a second default language in the
370
+ * editor: when more than one language says `default: true` (`defaults`,
371
+ * the ones that do, known once the files ran), `language()` refuses
372
+ * `default: true` for each of them, so they are both underlined and not
373
+ * only reported in the console. Nothing when at most one says it.
374
+ */
375
+ export function languageDefaultsDeclaration(defaults) {
376
+ const unique = [...new Set(defaults)].sort();
377
+ if (unique.length < 2)
378
+ return '';
379
+ return `export {};
380
+
381
+ declare module 'chapterjs' {
382
+ /** The languages that say \`default: true\`: only one can be the default, so \`default: true\` is refused in each of them until one gives it up. */
383
+ export interface ProjectLanguageDefaults {
384
+ ${unique.map(locale => ` ${JSON.stringify(locale)}: true;`).join('\n')}
385
+ }
386
+ }
387
+ `;
388
+ }
389
+ /** `export default language(` or `export const fr = language(`, with the config that follows. */
390
+ const DECLARED = /export\s+(?:default|const\s+([A-Za-z_$][\w$]*)\s*=)\s*language\s*\(\s*\{([^]*?)\}\s*\)/g;
391
+ /**
392
+ * The languages of a project, found without running its files: a text
393
+ * search for `language({ locale: '...' })` in the exports of each file.
394
+ * Approximate on purpose, for `sync`, which runs nothing: `dev` and
395
+ * `build` then write the exact ones. Returns the languages with the one
396
+ * that says `default: true`, when one does.
397
+ */
398
+ export async function scanLanguages(cwd) {
399
+ const languages = [];
400
+ let defaultLocale;
401
+ for (const { file, path } of await listSources(cwd)) {
402
+ const code = await readFile(path, 'utf8').catch(() => '');
403
+ if (!code.includes('language('))
404
+ continue;
405
+ for (const match of code.matchAll(DECLARED)) {
406
+ const config = match[2] ?? '';
407
+ const locale = /\blocale\s*:\s*['"`]([\w-]+)['"`]/.exec(config)?.[1];
408
+ if (!locale || !LOCALES.has(locale))
409
+ continue;
410
+ const exported = match[1] ?? 'default';
411
+ languages.push({
412
+ file,
413
+ export: exported,
414
+ name: exported === 'default' ? baseName(file) : exported,
415
+ locale: locale,
416
+ });
417
+ if (/\bdefault\s*:\s*true\b/.test(config)) {
418
+ defaultLocale ??= locale;
419
+ }
420
+ }
421
+ }
422
+ return defaultLocale === undefined
423
+ ? { languages }
424
+ : { languages, defaultLocale };
425
+ }
426
+ const baseName = (file) => file
427
+ .split('/')
428
+ .pop()
429
+ .replace(/\.[^.]+$/, '');
430
+ /**
431
+ * The augmentation that offers the commands of the project in the
432
+ * `commands` of a language: the name of each command, `true` when the
433
+ * language files describe it and `false` when the command has its own
434
+ * `description`, so that the editor only offers the first ones. Both come
435
+ * from running the files (`dev` and `build` know them, `sync` does not and
436
+ * offers any name). Nothing when the project has no command.
437
+ */
438
+ export function commandsDeclarations(commands) {
439
+ const entries = commands
440
+ .map(({ name, described }) => ` ${JSON.stringify(name)}: ${!described};`)
441
+ .sort();
442
+ if (entries.length === 0)
443
+ return '';
444
+ // A module (`export {}`), so that this augments 'chapterjs' instead of
445
+ // declaring it anew.
446
+ return `export {};
447
+
448
+ declare module 'chapterjs' {
449
+ /** The commands of the project, by name: true when the language files describe it, false when the command has its own description. */
450
+ export interface ProjectCommands {
451
+ ${entries.join('\n')}
452
+ }
453
+ }
454
+ `;
455
+ }
@@ -71,38 +71,49 @@ export interface FrameworkTexts {
71
71
  needsPermissions: MessageText;
72
72
  }
73
73
  /**
74
- * The commands of the project, as the editor sees them: the path of each
75
- * command file, `true` when the language files describe it, `false` when
76
- * the file has its own `description`. Filled in by the `.chapterjs/`
77
- * folder of the project, so that the `commands` of a language file offers
78
- * the ones it may translate. The options and choices are checked when the
74
+ * The commands of the project, as the editor sees them: the name of each
75
+ * command, `true` when the language files describe it, `false` when the
76
+ * command has its own `description`. Filled in by the `.chapterjs/` folder
77
+ * of the project, so that the `commands` of a language file offers the
78
+ * ones it may translate. The options and choices are checked when the
79
79
  * files load.
80
80
  */
81
81
  export interface ProjectCommands {
82
82
  }
83
- /** The paths of the commands the language files describe. */
83
+ /** The names of the commands the language files describe. */
84
84
  type TranslatableCommand = {
85
85
  [Path in keyof ProjectCommands]: ProjectCommands[Path] extends true ? Path : never;
86
86
  }[keyof ProjectCommands];
87
87
  /**
88
- * What Discord shows of the commands in one language: by the path of the
89
- * command file (`'ping'`, `'mod/ban'`), its name, description, options and
90
- * choices in that language, as the `locales` of a command file take them.
91
- * The editor offers the commands of the project that have no `description`
92
- * in their file (one that has owns its texts, `locales` included); their
93
- * options and choices are checked when the files load.
88
+ * What Discord shows of the commands in one language: by the name of the
89
+ * command (`'ping'`, `'mod ban'`), its name, description, options and
90
+ * choices in that language, as the `locales` of a command take them. The
91
+ * editor offers the commands of the project that have no `description`
92
+ * (one that has owns its texts, `locales` included); their options and
93
+ * choices are checked when the files load.
94
94
  */
95
95
  export type CommandsTranslations = [keyof ProjectCommands] extends [never] ? Readonly<Record<string, CommandTranslation>> : [TranslatableCommand] extends [never] ? Readonly<Record<string, never>> : {
96
96
  readonly [Path in TranslatableCommand]?: CommandTranslation;
97
97
  };
98
- /** What a file of `src/messages/` gives to `language()`. */
99
- export interface LanguageConfig<T extends MessageTexts = MessageTexts> {
98
+ /**
99
+ * The languages that say `default: true` when more than one does, as the
100
+ * editor sees them: one key per language. Filled in by the `.chapterjs/`
101
+ * folder of the project, so that each of them is underlined; empty when at
102
+ * most one language says it.
103
+ */
104
+ export interface ProjectLanguageDefaults {
105
+ }
106
+ /** What a file gives to `language()`. */
107
+ export interface LanguageConfig<T extends MessageTexts = MessageTexts, L extends Locale = Locale> {
108
+ /** The language these texts are in: `'en-US'`, `'fr'`... */
109
+ locale: L;
100
110
  /**
101
111
  * Whether this is the language used when the one of a person or of a
102
- * server is not in `src/messages/`. Exactly one file says so; with one
103
- * file, it goes without saying.
112
+ * server is not declared. Exactly one language says so; with one
113
+ * language, it goes without saying. When another language already says
114
+ * it, this is refused: only one can be the default.
104
115
  */
105
- default?: boolean;
116
+ default?: L extends keyof ProjectLanguageDefaults ? false : boolean;
106
117
  /** The texts of the bot in this language, by key. */
107
118
  texts: T;
108
119
  /** What Discord shows of the commands in this language. */
@@ -141,24 +152,24 @@ export type ParamsOf<T extends MessageText> = {
141
152
  /** The arguments of `t` after the key: the params, only when there are some. */
142
153
  export type ParamsArgs<T extends MessageText> = string extends T ? [params?: Record<string, string | number>] : [Placeholders<T>] extends [never] ? [] : [params: ParamsOf<T>];
143
154
  /**
144
- * The languages of `src/messages/`, as the editor sees them: one key per
145
- * file. Filled in by the `.chapterjs/` folder of the project, empty when
146
- * the folder has no language.
155
+ * The languages of the project, as the editor sees them: one key per
156
+ * language declared. Filled in by the `.chapterjs/` folder of the project,
157
+ * empty when the project has no language.
147
158
  */
148
159
  export interface ProjectLocales {
149
160
  }
150
- /** A language of `src/messages/`: `'en-US'`, `'fr'`... */
161
+ /** A language of the project: `'en-US'`, `'fr'`... */
151
162
  export type ProjectLocale = keyof ProjectLocales & string;
152
163
  /**
153
- * A language given to `t.in()`: the editor offers the ones of
154
- * `src/messages/`, and any language of Discord is accepted (the one of a
164
+ * A language given to `t.in()`: the editor offers the ones of the
165
+ * project, and any language of Discord is accepted (the one of a
155
166
  * person, of a server), `t` then taking the closest one declared. A word
156
167
  * that is no language of Discord is underlined, with the ones of the
157
168
  * project.
158
169
  */
159
170
  export type LocaleGiven<L extends string> = L extends Locale ? L : ProjectLocale;
160
171
  /**
161
- * `t`, as handlers receive it: gives a text of `src/messages/` in the
172
+ * `t`, as handlers receive it: gives a text of the language files in the
162
173
  * language of who will read the message, with its placeholders filled in.
163
174
  * `M` is the texts of the default language, which types the keys and the
164
175
  * params: a text another language does not have is taken from it.
@@ -177,43 +188,54 @@ export interface Translator<M extends object = ProjectMessages> {
177
188
  in<L extends ProjectLocale | (string & {})>(locale: LocaleGiven<L>): Translator<M>;
178
189
  }
179
190
  /**
180
- * The texts of `src/messages/`, as the editor sees them: the keys `t`
181
- * accepts and the placeholders of each. Filled in by the `.chapterjs/`
182
- * folder of the project when the folder has a language, empty otherwise.
191
+ * The texts of the project, as the editor sees them: the keys `t` accepts
192
+ * and the placeholders of each. Filled in by the `.chapterjs/` folder of
193
+ * the project when it has a language, empty otherwise.
183
194
  */
184
195
  export interface ProjectMessages {
185
196
  }
186
- /** What `t` is typed from, for a language file: its texts. */
187
- export type MessagesOf<F> = F extends LanguageFile<infer T> ? T : never;
197
+ /** What `t` is typed from, for a language: its texts. */
198
+ export type MessagesOf<F> = F extends LanguageDeclaration<infer T> ? T : never;
199
+ /**
200
+ * The texts of several declarations, the first one winning for a key
201
+ * several have: what `t` is typed from when a project has several
202
+ * languages, the default one first, then the others (a text the default
203
+ * language does not have is taken from the language that has it).
204
+ */
205
+ export type MergedMessages<T extends readonly object[]> = T extends readonly [
206
+ infer First extends object,
207
+ ...infer Rest extends readonly object[]
208
+ ] ? string extends keyof First ? MergedMessages<Rest> : First & Omit<MergedMessages<Rest>, keyof First> : {};
188
209
  /**
189
- * What every handler receives when the project has a language in
190
- * `src/messages/`: `t`. Nothing otherwise.
210
+ * What every handler receives when the project has a language: `t`.
211
+ * Nothing otherwise.
191
212
  */
192
213
  export type TranslationContext = [keyof ProjectMessages] extends [never] ? {} : {
193
- /** The texts of `src/messages/`, in the right language. */ t: Translator;
214
+ /** The texts of the language files, in the right language. */ t: Translator;
194
215
  };
195
- /** What `language()` returns: the default export of a file of `src/messages/`. */
196
- export interface LanguageFile<T extends MessageTexts = MessageTexts> {
216
+ /** What `language()` returns: a declaration the framework finds in the exports of a file. */
217
+ export interface LanguageDeclaration<T extends MessageTexts = MessageTexts, L extends Locale = Locale> {
197
218
  /** What the file gave to `language()`, not checked yet. */
198
- readonly config: LanguageConfig<T>;
219
+ readonly config: LanguageConfig<T, L>;
199
220
  }
200
221
  /**
201
- * Declares one language of the bot. Export the result as the default
202
- * export of a file of `src/messages/` named after the language
203
- * (`en-US.ts`, `fr.ts`...): every handler then receives `t`, which gives a
204
- * text in the language of who will read the message, with its
205
- * `{placeholders}` filled in.
222
+ * Declares one language of the bot, or more texts of it. Export the result
223
+ * from any file of `src/`; `locale` says which language: every handler
224
+ * then receives `t`, which gives a text in the language of who will read
225
+ * the message, with its `{placeholders}` filled in. A language may be
226
+ * declared in several places: the texts of a feature next to it, with the
227
+ * same `locale`, add to the others.
206
228
  *
207
229
  * ```ts
208
- * // src/messages/fr.ts
209
230
  * import { language } from 'chapterjs';
210
231
  *
211
232
  * export default language({
233
+ * locale: 'fr',
212
234
  * texts: { pong: 'Pong !', welcome: 'Bienvenue {name} !' },
213
235
  * });
214
236
  * ```
215
237
  */
216
- export declare function language<const T extends MessageTexts>(config: LanguageConfig<T>): LanguageFile<T>;
238
+ export declare function language<const T extends MessageTexts, L extends Locale>(config: LanguageConfig<T, L>): LanguageDeclaration<T, L>;
217
239
  /** Whether a value was made by `language()`. */
218
- export declare function isLanguageFile(value: unknown): value is LanguageFile;
240
+ export declare function isLanguageDeclaration(value: unknown): value is LanguageDeclaration;
219
241
  export {};
@@ -1,20 +1,21 @@
1
- // `language()`, as the files of `src/messages/` import it from 'chapterjs':
2
- // every text the bot answers with in one language, and `t`, which handlers
3
- // receive to pick the right one.
1
+ // `language()`, as user files import it from 'chapterjs': every text the
2
+ // bot answers with in one language, and `t`, which handlers receive to pick
3
+ // the right one.
4
4
  // https://docs.discord.com/developers/reference#locales
5
5
  const BRAND = Symbol.for('chapterjs.language');
6
6
  /**
7
- * Declares one language of the bot. Export the result as the default
8
- * export of a file of `src/messages/` named after the language
9
- * (`en-US.ts`, `fr.ts`...): every handler then receives `t`, which gives a
10
- * text in the language of who will read the message, with its
11
- * `{placeholders}` filled in.
7
+ * Declares one language of the bot, or more texts of it. Export the result
8
+ * from any file of `src/`; `locale` says which language: every handler
9
+ * then receives `t`, which gives a text in the language of who will read
10
+ * the message, with its `{placeholders}` filled in. A language may be
11
+ * declared in several places: the texts of a feature next to it, with the
12
+ * same `locale`, add to the others.
12
13
  *
13
14
  * ```ts
14
- * // src/messages/fr.ts
15
15
  * import { language } from 'chapterjs';
16
16
  *
17
17
  * export default language({
18
+ * locale: 'fr',
18
19
  * texts: { pong: 'Pong !', welcome: 'Bienvenue {name} !' },
19
20
  * });
20
21
  * ```
@@ -23,7 +24,7 @@ export function language(config) {
23
24
  return Object.freeze({ [BRAND]: true, config });
24
25
  }
25
26
  /** Whether a value was made by `language()`. */
26
- export function isLanguageFile(value) {
27
+ export function isLanguageDeclaration(value) {
27
28
  return (typeof value === 'object' &&
28
29
  value !== null &&
29
30
  value[BRAND] === true);