chapterjs 0.1.4 → 0.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/dist/cli/build.js +9 -10
- package/dist/cli/dev.js +29 -16
- package/dist/cli/project.d.ts +38 -25
- package/dist/cli/project.js +199 -134
- package/dist/cli/update-check.d.ts +42 -0
- package/dist/cli/update-check.js +125 -0
- package/dist/commands/command.d.ts +25 -11
- package/dist/commands/command.js +5 -5
- package/dist/commands/{convention.d.ts → declaration.d.ts} +24 -11
- package/dist/commands/{convention.js → declaration.js} +61 -33
- package/dist/commands/tree.d.ts +8 -4
- package/dist/commands/tree.js +16 -6
- package/dist/components/button.d.ts +13 -13
- package/dist/components/button.js +9 -11
- package/dist/components/component.d.ts +1 -1
- package/dist/components/component.js +1 -1
- package/dist/components/custom-id.d.ts +4 -4
- package/dist/components/custom-id.js +12 -12
- package/dist/components/declaration.d.ts +42 -0
- package/dist/components/{convention.js → declaration.js} +76 -73
- package/dist/components/declared.d.ts +29 -0
- package/dist/components/declared.js +49 -0
- package/dist/components/embed.d.ts +13 -12
- package/dist/components/embed.js +6 -6
- package/dist/components/instance.d.ts +3 -3
- package/dist/components/instance.js +2 -2
- package/dist/components/modal.d.ts +11 -10
- package/dist/components/modal.js +7 -8
- package/dist/components/router.d.ts +3 -2
- package/dist/components/router.js +14 -19
- package/dist/components/select.d.ts +14 -12
- package/dist/components/select.js +4 -4
- package/dist/events/declaration.d.ts +16 -0
- package/dist/events/declaration.js +80 -0
- package/dist/events/event.d.ts +42 -5
- package/dist/events/event.js +25 -11
- package/dist/events/registry.d.ts +1 -10
- package/dist/events/router.d.ts +3 -2
- package/dist/index.d.ts +11 -9
- package/dist/index.js +2 -0
- package/dist/loader/generated.d.ts +5 -32
- package/dist/loader/generated.js +21 -55
- package/dist/loader/loader.d.ts +57 -44
- package/dist/loader/loader.js +73 -89
- package/dist/main.d.ts +0 -1
- package/dist/main.js +2 -3
- package/dist/messages/commands.d.ts +3 -3
- package/dist/messages/commands.js +29 -10
- package/dist/messages/declaration.d.ts +89 -0
- package/dist/messages/declaration.js +455 -0
- package/dist/messages/messages.d.ts +65 -43
- package/dist/messages/messages.js +11 -10
- package/dist/messages/phrases.js +1 -1
- package/dist/messages/translate.d.ts +4 -4
- package/dist/messages/translate.js +4 -4
- package/dist/presence/declaration.d.ts +19 -0
- package/dist/presence/{convention.js → declaration.js} +13 -28
- package/dist/presence/presence.d.ts +7 -7
- package/dist/presence/presence.js +5 -5
- package/dist/structures/context.d.ts +1 -1
- package/dist/structures/interaction.d.ts +2 -2
- package/dist/structures/interaction.js +3 -3
- package/dist/structures/payload.d.ts +4 -4
- package/dist/structures/payload.js +4 -4
- package/dist/tasks/declaration.d.ts +16 -0
- package/dist/tasks/{convention.js → declaration.js} +14 -36
- package/dist/tasks/scheduler.d.ts +3 -1
- package/dist/tasks/scheduler.js +4 -4
- package/dist/tasks/task.d.ts +8 -9
- package/dist/tasks/task.js +6 -7
- package/package.json +1 -1
- package/dist/components/convention.d.ts +0 -35
- package/dist/components/file.d.ts +0 -30
- package/dist/components/file.js +0 -52
- package/dist/events/convention.d.ts +0 -25
- package/dist/events/convention.js +0 -97
- package/dist/events/types.d.ts +0 -7
- package/dist/events/types.js +0 -48
- package/dist/messages/convention.d.ts +0 -89
- package/dist/messages/convention.js +0 -347
- package/dist/presence/convention.d.ts +0 -26
- package/dist/tasks/convention.d.ts +0 -26
package/dist/components/file.js
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
// What `button()`, `select()`, `modal()` and `embed()` return: the default
|
|
2
|
-
// export of a file of `src/components/`. It is a function (called with the
|
|
3
|
-
// data the component carries) that the loader recognises, checks, and binds
|
|
4
|
-
// to the path of its file: that path is the custom_id. Until then it can't
|
|
5
|
-
// make anything, which is how a definition outside `src/components/` is
|
|
6
|
-
// refused.
|
|
7
|
-
import { piece, pieceFunction, } from './instance.js';
|
|
8
|
-
export const COMPONENT_FOLDERS = {
|
|
9
|
-
buttons: 'button',
|
|
10
|
-
selects: 'select',
|
|
11
|
-
modals: 'modal',
|
|
12
|
-
embeds: 'embed',
|
|
13
|
-
};
|
|
14
|
-
const FILE = Symbol.for('chapterjs.component.file');
|
|
15
|
-
/** The state of a component file, if the value is one. */
|
|
16
|
-
export function fileStateOf(value) {
|
|
17
|
-
if (typeof value !== 'function')
|
|
18
|
-
return undefined;
|
|
19
|
-
return value[FILE];
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Makes a component file. `asPiece` says whether the file itself stands
|
|
23
|
-
* for its instance when it carries no data (a button, a menu, a modal: not
|
|
24
|
-
* an embed, which is only a function).
|
|
25
|
-
*/
|
|
26
|
-
export function createFile(kind, config, { asPiece, pieceKind }) {
|
|
27
|
-
const state = { kind, config, made: `${kind}()` };
|
|
28
|
-
const notBound = () => {
|
|
29
|
-
throw new Error(`This ${kind} was not loaded by ChapterJS: a ${kind} is a file of src/components/${Object.keys(COMPONENT_FOLDERS).find(folder => COMPONENT_FOLDERS[folder] === kind)}/, exported by default, and used after the bot started.`);
|
|
30
|
-
};
|
|
31
|
-
const file = (...args) => {
|
|
32
|
-
if (!state.bound)
|
|
33
|
-
return notBound();
|
|
34
|
-
const rendered = state.bound.render(args);
|
|
35
|
-
return pieceKind
|
|
36
|
-
? piece(pieceKind, rendered.raw)
|
|
37
|
-
: rendered;
|
|
38
|
-
};
|
|
39
|
-
Object.defineProperty(file, FILE, { value: state });
|
|
40
|
-
Object.defineProperty(file, 'config', { value: config, enumerable: true });
|
|
41
|
-
if (asPiece) {
|
|
42
|
-
pieceFunction(file, () => state.bound ? state.bound.render([]) : notBound());
|
|
43
|
-
}
|
|
44
|
-
return file;
|
|
45
|
-
}
|
|
46
|
-
/** Binds a file to its path and to what makes its instances. */
|
|
47
|
-
export function bindFile(file, bound) {
|
|
48
|
-
const state = fileStateOf(file);
|
|
49
|
-
if (!state)
|
|
50
|
-
throw new TypeError('Not a component file.');
|
|
51
|
-
state.bound = bound;
|
|
52
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import type { Convention } from '../loader/loader.js';
|
|
2
|
-
import { type EventContexts, type EventName } from './registry.js';
|
|
3
|
-
/** One reaction to an event: a file of `src/events/<event>/`. */
|
|
4
|
-
export interface EventHandler<Name extends EventName = EventName> {
|
|
5
|
-
/** The event, which is the name of the folder the file is in. */
|
|
6
|
-
readonly name: Name;
|
|
7
|
-
readonly handler: (context: EventContexts[Name]) => unknown;
|
|
8
|
-
/** The options the file gave to `event()`, checked. */
|
|
9
|
-
readonly options: Readonly<Record<string, unknown>>;
|
|
10
|
-
}
|
|
11
|
-
/**
|
|
12
|
-
* `src/events/`: one folder per event, named after it, and in it one file
|
|
13
|
-
* per reaction. The name of a file is free; it exports by default what
|
|
14
|
-
* `event()` returns:
|
|
15
|
-
*
|
|
16
|
-
* ```ts
|
|
17
|
-
* // src/events/memberJoin/welcome.ts
|
|
18
|
-
* import { event } from 'chapterjs';
|
|
19
|
-
*
|
|
20
|
-
* export default event(async ({ member, guild }) => {
|
|
21
|
-
* await member.send(`Welcome to ${guild.name}!`);
|
|
22
|
-
* });
|
|
23
|
-
* ```
|
|
24
|
-
*/
|
|
25
|
-
export declare const eventsConvention: Convention<EventHandler>;
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import { isEventFile } from './event.js';
|
|
2
|
-
import { EVENT_NAMES, EVENTS, isEventName, } from './registry.js';
|
|
3
|
-
/** Checks the second argument of `event()` against what the event accepts. */
|
|
4
|
-
function readOptions(name, options) {
|
|
5
|
-
if (options === undefined)
|
|
6
|
-
return Object.freeze({});
|
|
7
|
-
const allowed = EVENTS[name].options ?? {};
|
|
8
|
-
const names = Object.keys(allowed);
|
|
9
|
-
const first = names[0];
|
|
10
|
-
const kind = first === undefined ? undefined : allowed[first];
|
|
11
|
-
const example = first === undefined
|
|
12
|
-
? 'option: true'
|
|
13
|
-
: `${first}: ${kind === 'boolean' || !kind ? 'true' : `'${kind.at(-1)}'`}`;
|
|
14
|
-
const list = names.length === 0
|
|
15
|
-
? `${name} has no options: remove the second argument of event().`
|
|
16
|
-
: `Options of ${name} are: ${names.join(', ')}.`;
|
|
17
|
-
if (typeof options !== 'object' ||
|
|
18
|
-
options === null ||
|
|
19
|
-
Array.isArray(options)) {
|
|
20
|
-
throw new TypeError(`The second argument of event() is its options, like { ${example} }. ${list}`);
|
|
21
|
-
}
|
|
22
|
-
for (const [key, value] of Object.entries(options)) {
|
|
23
|
-
if (!Object.hasOwn(allowed, key)) {
|
|
24
|
-
throw new TypeError(`"${key}" is not an option of ${name}. ${list}`);
|
|
25
|
-
}
|
|
26
|
-
const kind = allowed[key];
|
|
27
|
-
const got = JSON.stringify(value) ?? typeof value;
|
|
28
|
-
if (kind === 'boolean') {
|
|
29
|
-
if (typeof value !== 'boolean') {
|
|
30
|
-
throw new TypeError(`The option ${key} of ${name} is true or false, got ${got}.`);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
else if (!kind.includes(value)) {
|
|
34
|
-
throw new TypeError(`The option ${key} of ${name} is ${kind
|
|
35
|
-
.map(word => `'${word}'`)
|
|
36
|
-
.join(', ')
|
|
37
|
-
.replace(/, ([^,]*)$/, ' or $1')}, got ${got}.`);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
return Object.freeze({ ...options });
|
|
41
|
-
}
|
|
42
|
-
/** " Did you mean "x"?" when a name is close to a real one. */
|
|
43
|
-
function suggest(name) {
|
|
44
|
-
const wanted = name.toLowerCase().replace(/[^a-z]/g, '');
|
|
45
|
-
if (wanted === '')
|
|
46
|
-
return '';
|
|
47
|
-
const close = EVENT_NAMES.find(event => {
|
|
48
|
-
const real = event.toLowerCase();
|
|
49
|
-
return real === wanted || real.includes(wanted) || wanted.includes(real);
|
|
50
|
-
});
|
|
51
|
-
return close ? ` Did you mean "${close}"?` : '';
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* `src/events/`: one folder per event, named after it, and in it one file
|
|
55
|
-
* per reaction. The name of a file is free; it exports by default what
|
|
56
|
-
* `event()` returns:
|
|
57
|
-
*
|
|
58
|
-
* ```ts
|
|
59
|
-
* // src/events/memberJoin/welcome.ts
|
|
60
|
-
* import { event } from 'chapterjs';
|
|
61
|
-
*
|
|
62
|
-
* export default event(async ({ member, guild }) => {
|
|
63
|
-
* await member.send(`Welcome to ${guild.name}!`);
|
|
64
|
-
* });
|
|
65
|
-
* ```
|
|
66
|
-
*/
|
|
67
|
-
export const eventsConvention = {
|
|
68
|
-
folder: 'events',
|
|
69
|
-
one: 'event',
|
|
70
|
-
many: 'events',
|
|
71
|
-
/** The event of a file, from its path: known without running the file. */
|
|
72
|
-
check(path) {
|
|
73
|
-
const parts = path.split('/');
|
|
74
|
-
if (parts.length < 2) {
|
|
75
|
-
throw new TypeError(`This file is directly in src/events/. Put it in a folder named after the event it reacts to, like src/events/messageCreate/${path}. Events are: ${EVENT_NAMES.join(', ')}.`);
|
|
76
|
-
}
|
|
77
|
-
const name = parts[0];
|
|
78
|
-
if (!isEventName(name)) {
|
|
79
|
-
throw new TypeError(`The folder src/events/${name} is not named after an event.${suggest(name)} Events are: ${EVENT_NAMES.join(', ')}.`);
|
|
80
|
-
}
|
|
81
|
-
},
|
|
82
|
-
read(exports, path) {
|
|
83
|
-
const name = path.split('/')[0];
|
|
84
|
-
const file = exports.default;
|
|
85
|
-
if (!isEventFile(file)) {
|
|
86
|
-
const example = `import { event } from 'chapterjs'; export default event(({ ... }) => { ... })`;
|
|
87
|
-
throw new TypeError('default' in exports
|
|
88
|
-
? `The default export of this file must be what event() returns: ${example}`
|
|
89
|
-
: `This file has no default export. It should look like: ${example}`);
|
|
90
|
-
}
|
|
91
|
-
return Object.freeze({
|
|
92
|
-
name,
|
|
93
|
-
handler: file.handler,
|
|
94
|
-
options: readOptions(name, file.options),
|
|
95
|
-
});
|
|
96
|
-
},
|
|
97
|
-
};
|
package/dist/events/types.d.ts
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import type { TypedFolder } from '../loader/generated.js';
|
|
2
|
-
/**
|
|
3
|
-
* One typed folder per event: in `src/events/<event>/`, the `event()`
|
|
4
|
-
* imported from 'chapterjs' gives its handler what that event gives.
|
|
5
|
-
* Written for every event, so a new folder is typed as soon as it exists.
|
|
6
|
-
*/
|
|
7
|
-
export declare function eventTypedFolders(): TypedFolder[];
|
package/dist/events/types.js
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { EVENT_NAMES, EVENTS } from './registry.js';
|
|
2
|
-
/**
|
|
3
|
-
* One typed folder per event: in `src/events/<event>/`, the `event()`
|
|
4
|
-
* imported from 'chapterjs' gives its handler what that event gives.
|
|
5
|
-
* Written for every event, so a new folder is typed as soon as it exists.
|
|
6
|
-
*/
|
|
7
|
-
export function eventTypedFolders() {
|
|
8
|
-
return EVENT_NAMES.map(name => {
|
|
9
|
-
const hasOptions = EVENTS[name].options !== undefined;
|
|
10
|
-
return {
|
|
11
|
-
id: `events.${name}`,
|
|
12
|
-
// At any depth: the event folder may be inside `(group)` folders.
|
|
13
|
-
folder: `src/events/**/${name}`,
|
|
14
|
-
declarations: hasOptions
|
|
15
|
-
? `import type { ContextOf, EventFile, EventOptions } from '#chapterjs';
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Says what to do when \`${name}\` happens. Export the result as the default
|
|
19
|
-
* export of a file of src/events/${name}/.
|
|
20
|
-
*
|
|
21
|
-
* What the function receives follows the options: what an option lets
|
|
22
|
-
* through is only there to handle in the files that turn it on.
|
|
23
|
-
*/
|
|
24
|
-
export declare function event<
|
|
25
|
-
const Options extends EventOptions['${name}'] = {},
|
|
26
|
-
>(
|
|
27
|
-
handler: (context: ContextOf<'${name}', Options>) => unknown,
|
|
28
|
-
// The options of the event come first so editors offer them; the rest
|
|
29
|
-
// makes a misspelled option an error instead of something ignored.
|
|
30
|
-
options?: EventOptions['${name}'] &
|
|
31
|
-
Options & {
|
|
32
|
-
[Key in Exclude<keyof Options, keyof EventOptions['${name}']>]: never;
|
|
33
|
-
}
|
|
34
|
-
): EventFile;
|
|
35
|
-
`
|
|
36
|
-
: `import type { ContextOf, EventFile } from '#chapterjs';
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Says what to do when \`${name}\` happens. Export the result as the default
|
|
40
|
-
* export of a file of src/events/${name}/.
|
|
41
|
-
*/
|
|
42
|
-
export declare function event(
|
|
43
|
-
handler: (context: ContextOf<'${name}'>) => unknown
|
|
44
|
-
): EventFile;
|
|
45
|
-
`,
|
|
46
|
-
};
|
|
47
|
-
});
|
|
48
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import type { Locale } from '../discord/types/common.js';
|
|
2
|
-
import type { TypedFolder } from '../loader/generated.js';
|
|
3
|
-
import { type Convention, type FailedFile, type FoundFile } from '../loader/loader.js';
|
|
4
|
-
import { type LoadedMessages, type Template } from './translate.js';
|
|
5
|
-
/** A language file, checked: what the languages are assembled from. */
|
|
6
|
-
export interface LoadedLanguage {
|
|
7
|
-
/** The language, from the name of the file. */
|
|
8
|
-
readonly locale: Locale;
|
|
9
|
-
/** Whether the file says it is the default language. */
|
|
10
|
-
readonly isDefault: boolean;
|
|
11
|
-
/** The texts, compiled. */
|
|
12
|
-
readonly texts: ReadonlyMap<string, Template>;
|
|
13
|
-
/** The phrases of the framework this file translates, compiled. */
|
|
14
|
-
readonly framework: ReadonlyMap<string, Template>;
|
|
15
|
-
/** The `commands` section, by the path of the command file, not checked yet. */
|
|
16
|
-
readonly commands: Readonly<Record<string, unknown>> | undefined;
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* `src/messages/`: one file per language. Each file exports by default
|
|
20
|
-
* what `language()` returns:
|
|
21
|
-
*
|
|
22
|
-
* ```ts
|
|
23
|
-
* // src/messages/fr.ts
|
|
24
|
-
* import { language } from 'chapterjs';
|
|
25
|
-
*
|
|
26
|
-
* export default language({ texts: { pong: 'Pong !' } });
|
|
27
|
-
* ```
|
|
28
|
-
*/
|
|
29
|
-
export declare const languagesConvention: Convention<LoadedLanguage>;
|
|
30
|
-
/** A language file as the project loaded it. */
|
|
31
|
-
export interface LanguageEntry {
|
|
32
|
-
readonly file: string;
|
|
33
|
-
readonly language: LoadedLanguage;
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Puts the languages together: one default, which `t` is typed from and
|
|
37
|
-
* gives a text to the languages that do not have it, and every other
|
|
38
|
-
* language with texts the default has, the same placeholders included. A
|
|
39
|
-
* language that does not match is left out and reported on its file: the
|
|
40
|
-
* others still work. `null` when there is no language at all.
|
|
41
|
-
*/
|
|
42
|
-
export declare function assembleMessages(entries: readonly LanguageEntry[]): {
|
|
43
|
-
messages: LoadedMessages | null;
|
|
44
|
-
failed: FailedFile[];
|
|
45
|
-
};
|
|
46
|
-
/**
|
|
47
|
-
* The augmentation that types `t` from the files themselves: the editor
|
|
48
|
-
* reads the default language of `src/messages/` directly, so the keys
|
|
49
|
-
* and placeholders follow every keystroke; the other languages take the
|
|
50
|
-
* texts they do not have from it. Which file is the default is known
|
|
51
|
-
* once the files ran (`defaultFile`, from the loaded messages): until
|
|
52
|
-
* then (`sync`), the first file is taken. Nothing when the folder has no
|
|
53
|
-
* language.
|
|
54
|
-
*/
|
|
55
|
-
export declare function messagesDeclarations(files: readonly FoundFile[], defaultFile?: string): string;
|
|
56
|
-
/**
|
|
57
|
-
* One typed folder per language file: in `src/messages/fr.ts`, the
|
|
58
|
-
* `language()` imported from 'chapterjs' knows the other files. When
|
|
59
|
-
* another one says `default: true` (`defaults`, the files that do, known
|
|
60
|
-
* once they ran: `dev` and `build` give them, `sync` runs nothing), it
|
|
61
|
-
* refuses `default: true`, naming that file: a second default language is
|
|
62
|
-
* underlined in the editor, and no longer only reported in the console.
|
|
63
|
-
* Each project takes `main` (the commands a language file may translate)
|
|
64
|
-
* and not `shared`, which types `t` from the default file: a language file
|
|
65
|
-
* checked with the `language()` of another would be a false error.
|
|
66
|
-
*/
|
|
67
|
-
export declare function languageTypedFolders(files: readonly FoundFile[], defaults?: readonly string[]): TypedFolder[];
|
|
68
|
-
/** A command file, and whether it has its own `description`. */
|
|
69
|
-
export interface DeclaredCommand {
|
|
70
|
-
/** `src/commands/(mod)/mod/ban.ts` */
|
|
71
|
-
readonly file: string;
|
|
72
|
-
/**
|
|
73
|
-
* Whether the file gives its own texts; `false` when it is not known,
|
|
74
|
-
* like before the file ran: the language files may then describe it.
|
|
75
|
-
*/
|
|
76
|
-
readonly described: boolean;
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* The augmentation that offers the commands of the project in the
|
|
80
|
-
* `commands` of a language file: the path of each command file, `true`
|
|
81
|
-
* when the language files describe it and `false` when the file has its
|
|
82
|
-
* own `description`, so that the editor only offers the first ones. The
|
|
83
|
-
* path comes from the file name; whether it is described comes from
|
|
84
|
-
* running the file (`dev` and `build` know it, `sync` does not). The
|
|
85
|
-
* command files themselves are never referred to: their `t` is typed from
|
|
86
|
-
* the languages, which would be a circle. Nothing when the project has no
|
|
87
|
-
* command.
|
|
88
|
-
*/
|
|
89
|
-
export declare function commandsDeclarations(commands: readonly DeclaredCommand[]): string;
|
|
@@ -1,347 +0,0 @@
|
|
|
1
|
-
// `src/messages/`: one file per language, named after it (`fr.ts` is the
|
|
2
|
-
// French of the bot). What a file declares is checked when it loads, and
|
|
3
|
-
// the languages are checked against each other once loaded: every one has
|
|
4
|
-
// the texts of the default language, with the same placeholders, so a
|
|
5
|
-
// missing text is explained before anyone asks for it.
|
|
6
|
-
import { commandName } from '../commands/tree.js';
|
|
7
|
-
import { logicalPath, } from '../loader/loader.js';
|
|
8
|
-
import { isLanguageFile } from './messages.js';
|
|
9
|
-
import { FRAMEWORK, FRAMEWORK_KEYS } from './phrases.js';
|
|
10
|
-
import { compile, compilePlural, LOCALES, PLURAL_FORMS, } from './translate.js';
|
|
11
|
-
const fail = message => {
|
|
12
|
-
throw new TypeError(message);
|
|
13
|
-
};
|
|
14
|
-
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
15
|
-
const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
|
-
const KEYS = ['default', 'texts', 'commands', 'framework'];
|
|
17
|
-
const EXAMPLE = `language({ texts: { pong: 'Pong!' } })`;
|
|
18
|
-
/** Placeholders, for a message: `{name}, {count}`, or `none`. */
|
|
19
|
-
const showParams = (names) => names.length === 0 ? 'none' : names.map(name => `{${name}}`).join(', ');
|
|
20
|
-
/** Whether two templates take the same placeholders. */
|
|
21
|
-
const samePlaceholders = (a, b) => [...a.params].sort().join('\0') === [...b.params].sort().join('\0');
|
|
22
|
-
/** One form of a text, cut around its placeholders, each one checked. */
|
|
23
|
-
function readForm(what, text, example) {
|
|
24
|
-
if (typeof text !== 'string' || text === '') {
|
|
25
|
-
fail(`${what} must be a text: ${example}`);
|
|
26
|
-
}
|
|
27
|
-
const template = compile(text);
|
|
28
|
-
for (const name of template.params) {
|
|
29
|
-
if (!NAME.test(name)) {
|
|
30
|
-
fail(`${what} has a placeholder "{${name}}": a placeholder is a name made of letters, digits and _, like {name}.`);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return template;
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* A text of a language file: a string, or a plural (one form per
|
|
37
|
-
* quantity, `other` at least), with an error saying what to write.
|
|
38
|
-
*/
|
|
39
|
-
function readText(key, text, kind = 'message') {
|
|
40
|
-
const what = `The ${kind} "${key}"`;
|
|
41
|
-
if (isRecord(text)) {
|
|
42
|
-
const forms = new Map();
|
|
43
|
-
for (const [form, one] of Object.entries(text)) {
|
|
44
|
-
if (!PLURAL_FORMS.includes(form)) {
|
|
45
|
-
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' }`);
|
|
46
|
-
}
|
|
47
|
-
forms.set(form, readForm(`The form "${form}" of the plural "${key}"`, one, `${form}: '{count} members'`));
|
|
48
|
-
}
|
|
49
|
-
if (!forms.has('other')) {
|
|
50
|
-
fail(`The plural "${key}" needs an "other" form, used when no other form fits: ${key}: { one: '{count} member', other: '{count} members' }`);
|
|
51
|
-
}
|
|
52
|
-
return compilePlural(forms);
|
|
53
|
-
}
|
|
54
|
-
return readForm(what, text, `${key}: 'Hello {name}!'${kind === 'message' ? `, or a plural: ${key}: { one: '{count} member', other: '{count} members' }` : ''}`);
|
|
55
|
-
}
|
|
56
|
-
/** The language a file is named after, or an error saying which ones exist. */
|
|
57
|
-
function localeOf(path) {
|
|
58
|
-
const name = path.replace(/\.[^./]+$/, '');
|
|
59
|
-
if (name.includes('/')) {
|
|
60
|
-
fail(`A language is a file directly in src/messages/, named after the language: src/messages/${name.split('/').pop()}.ts. It can't be in a subfolder.`);
|
|
61
|
-
}
|
|
62
|
-
if (!LOCALES.has(name)) {
|
|
63
|
-
fail(`"${name}" is not a language Discord knows: the name of a file of src/messages/ is the language it holds. It can be: ${[...LOCALES].join(', ')}.`);
|
|
64
|
-
}
|
|
65
|
-
return name;
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* `src/messages/`: one file per language. Each file exports by default
|
|
69
|
-
* what `language()` returns:
|
|
70
|
-
*
|
|
71
|
-
* ```ts
|
|
72
|
-
* // src/messages/fr.ts
|
|
73
|
-
* import { language } from 'chapterjs';
|
|
74
|
-
*
|
|
75
|
-
* export default language({ texts: { pong: 'Pong !' } });
|
|
76
|
-
* ```
|
|
77
|
-
*/
|
|
78
|
-
export const languagesConvention = {
|
|
79
|
-
folder: 'messages',
|
|
80
|
-
one: 'language',
|
|
81
|
-
many: 'languages',
|
|
82
|
-
check(path) {
|
|
83
|
-
localeOf(path);
|
|
84
|
-
},
|
|
85
|
-
read(exports, path) {
|
|
86
|
-
const locale = localeOf(path);
|
|
87
|
-
const file = exports.default;
|
|
88
|
-
const example = `import { language } from 'chapterjs'; export default ${EXAMPLE}`;
|
|
89
|
-
if (!isLanguageFile(file)) {
|
|
90
|
-
return fail('default' in exports
|
|
91
|
-
? `The default export of this file must be what language() returns: ${example}`
|
|
92
|
-
: `This file has no default export. It should look like: ${example}`);
|
|
93
|
-
}
|
|
94
|
-
const config = file.config;
|
|
95
|
-
if (!isRecord(config)) {
|
|
96
|
-
return fail(`language() needs an object: ${EXAMPLE}`);
|
|
97
|
-
}
|
|
98
|
-
for (const key of Object.keys(config)) {
|
|
99
|
-
if (!KEYS.includes(key)) {
|
|
100
|
-
fail(`"${key}" is not something a language has. It can have: ${KEYS.join(', ')}.`);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
if (config.default !== undefined && typeof config.default !== 'boolean') {
|
|
104
|
-
fail(`"default" is true or false, got ${JSON.stringify(config.default)}.`);
|
|
105
|
-
}
|
|
106
|
-
if (!isRecord(config.texts)) {
|
|
107
|
-
fail(`"texts" is an object, one text per key: texts: { pong: 'Pong!', welcome: 'Welcome {name}!' }`);
|
|
108
|
-
}
|
|
109
|
-
const texts = new Map();
|
|
110
|
-
for (const [key, text] of Object.entries(config.texts)) {
|
|
111
|
-
texts.set(key, readText(key, text));
|
|
112
|
-
}
|
|
113
|
-
const framework = new Map();
|
|
114
|
-
if (config.framework !== undefined) {
|
|
115
|
-
if (!isRecord(config.framework)) {
|
|
116
|
-
fail(`"framework" is an object, one phrase per key: framework: { guildOnly: '...' }. It can have: ${FRAMEWORK_KEYS.join(', ')}.`);
|
|
117
|
-
}
|
|
118
|
-
for (const [key, text] of Object.entries(config.framework)) {
|
|
119
|
-
const expected = FRAMEWORK.get(key);
|
|
120
|
-
if (!expected) {
|
|
121
|
-
fail(`"${key}" is not a phrase of the framework. It can be: ${FRAMEWORK_KEYS.join(', ')}.`);
|
|
122
|
-
}
|
|
123
|
-
const template = readText(key, text, 'phrase');
|
|
124
|
-
if (!samePlaceholders(template, expected)) {
|
|
125
|
-
fail(`The phrase "${key}" does not have the placeholders of the framework: it has ${showParams(template.params)}, the framework has ${showParams(expected.params)}.`);
|
|
126
|
-
}
|
|
127
|
-
framework.set(key, template);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
if (config.commands !== undefined) {
|
|
131
|
-
if (!isRecord(config.commands)) {
|
|
132
|
-
fail(`"commands" is an object whose keys are the paths of your command files: commands: { ping: { description: '...' }, 'mod/ban': { name: '...' } }`);
|
|
133
|
-
}
|
|
134
|
-
for (const [path, translation] of Object.entries(config.commands)) {
|
|
135
|
-
if (!/^[a-z0-9_-]+(?:\/[a-z0-9_-]+){0,2}$/.test(path)) {
|
|
136
|
-
fail(`"${path}" in "commands" is not the path of a command file: write it as the file is named, like 'ping' or 'mod/ban'.`);
|
|
137
|
-
}
|
|
138
|
-
if (!isRecord(translation)) {
|
|
139
|
-
fail(`The texts of the command ${commandName(path.split('/'))} must be an object like { name: '...', description: '...' }.`);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
return {
|
|
144
|
-
locale,
|
|
145
|
-
isDefault: config.default === true,
|
|
146
|
-
texts,
|
|
147
|
-
framework,
|
|
148
|
-
commands: config.commands,
|
|
149
|
-
};
|
|
150
|
-
},
|
|
151
|
-
};
|
|
152
|
-
/**
|
|
153
|
-
* Puts the languages together: one default, which `t` is typed from and
|
|
154
|
-
* gives a text to the languages that do not have it, and every other
|
|
155
|
-
* language with texts the default has, the same placeholders included. A
|
|
156
|
-
* language that does not match is left out and reported on its file: the
|
|
157
|
-
* others still work. `null` when there is no language at all.
|
|
158
|
-
*/
|
|
159
|
-
export function assembleMessages(entries) {
|
|
160
|
-
if (entries.length === 0)
|
|
161
|
-
return { messages: null, failed: [] };
|
|
162
|
-
const failed = [];
|
|
163
|
-
const defaults = entries.filter(({ language }) => language.isDefault);
|
|
164
|
-
let reference;
|
|
165
|
-
if (entries.length === 1) {
|
|
166
|
-
reference = entries[0];
|
|
167
|
-
}
|
|
168
|
-
else if (defaults.length === 1) {
|
|
169
|
-
reference = defaults[0];
|
|
170
|
-
}
|
|
171
|
-
else if (defaults.length === 0) {
|
|
172
|
-
// No default: nothing can be assembled.
|
|
173
|
-
return {
|
|
174
|
-
messages: null,
|
|
175
|
-
failed: entries.map(({ file }) => ({
|
|
176
|
-
file,
|
|
177
|
-
error: new TypeError(`None of the ${entries.length} languages of src/messages/ 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.`),
|
|
178
|
-
})),
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
return {
|
|
183
|
-
messages: null,
|
|
184
|
-
failed: defaults.map(({ file }) => ({
|
|
185
|
-
file,
|
|
186
|
-
error: new TypeError(`${defaults.length} languages say default: true (${defaults.map(({ file }) => file).join(', ')}): only one can be the default.`),
|
|
187
|
-
})),
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
const locales = new Map();
|
|
191
|
-
const framework = new Map();
|
|
192
|
-
const commands = new Map();
|
|
193
|
-
const files = new Map();
|
|
194
|
-
const name = reference.file.split('/').pop();
|
|
195
|
-
for (const entry of entries) {
|
|
196
|
-
const { file, language } = entry;
|
|
197
|
-
try {
|
|
198
|
-
// A text a language does not have is taken from the default one;
|
|
199
|
-
// one the default does not have is dead: `t` offers the default's.
|
|
200
|
-
if (entry !== reference) {
|
|
201
|
-
for (const [key, template] of language.texts) {
|
|
202
|
-
const expected = reference.language.texts.get(key);
|
|
203
|
-
if (!expected) {
|
|
204
|
-
fail(`The message "${key}" is not in ${name}: add it there, or remove it here.`);
|
|
205
|
-
}
|
|
206
|
-
if (!samePlaceholders(template, expected)) {
|
|
207
|
-
fail(`The message "${key}" does not have the placeholders of ${name}: it has ${showParams([...template.params].sort())}, ${name} has ${showParams([...expected.params].sort())}.`);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
locales.set(language.locale, language.texts);
|
|
212
|
-
if (language.framework.size > 0) {
|
|
213
|
-
framework.set(language.locale, language.framework);
|
|
214
|
-
}
|
|
215
|
-
files.set(language.locale, file);
|
|
216
|
-
if (language.commands)
|
|
217
|
-
commands.set(language.locale, language.commands);
|
|
218
|
-
}
|
|
219
|
-
catch (error) {
|
|
220
|
-
failed.push({ file, error });
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
return {
|
|
224
|
-
messages: {
|
|
225
|
-
default: reference.language.locale,
|
|
226
|
-
locales,
|
|
227
|
-
framework,
|
|
228
|
-
commands,
|
|
229
|
-
files,
|
|
230
|
-
},
|
|
231
|
-
failed,
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
/**
|
|
235
|
-
* The augmentation that types `t` from the files themselves: the editor
|
|
236
|
-
* reads the default language of `src/messages/` directly, so the keys
|
|
237
|
-
* and placeholders follow every keystroke; the other languages take the
|
|
238
|
-
* texts they do not have from it. Which file is the default is known
|
|
239
|
-
* once the files ran (`defaultFile`, from the loaded messages): until
|
|
240
|
-
* then (`sync`), the first file is taken. Nothing when the folder has no
|
|
241
|
-
* language.
|
|
242
|
-
*/
|
|
243
|
-
export function messagesDeclarations(files, defaultFile) {
|
|
244
|
-
const named = namedFiles(files);
|
|
245
|
-
const first = named.find(({ file }) => file === defaultFile) ?? named[0];
|
|
246
|
-
if (!first)
|
|
247
|
-
return '';
|
|
248
|
-
const path = first.file.replace(/^src\//, '').replace(/\.[^./]+$/, '');
|
|
249
|
-
const locales = named
|
|
250
|
-
.map(({ locale }) => ` ${JSON.stringify(locale)}: true;`)
|
|
251
|
-
.sort()
|
|
252
|
-
.join('\n');
|
|
253
|
-
return `import type { MessagesOf } from '#chapterjs';
|
|
254
|
-
|
|
255
|
-
declare module 'chapterjs' {
|
|
256
|
-
/** The texts of src/messages/, as ${first.file} writes them: what \`t\` accepts. */
|
|
257
|
-
export interface ProjectMessages
|
|
258
|
-
extends MessagesOf<typeof import('../../src/${path}').default> {}
|
|
259
|
-
/** The languages of src/messages/: what \`t.in()\` offers. */
|
|
260
|
-
export interface ProjectLocales {
|
|
261
|
-
${locales}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
`;
|
|
265
|
-
}
|
|
266
|
-
/** The files of `src/messages/` named after a language, with it. */
|
|
267
|
-
function namedFiles(files) {
|
|
268
|
-
return files.flatMap(({ file }) => {
|
|
269
|
-
try {
|
|
270
|
-
return [{ file, locale: localeOf(file.slice('src/messages/'.length)) }];
|
|
271
|
-
}
|
|
272
|
-
catch {
|
|
273
|
-
return [];
|
|
274
|
-
}
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* One typed folder per language file: in `src/messages/fr.ts`, the
|
|
279
|
-
* `language()` imported from 'chapterjs' knows the other files. When
|
|
280
|
-
* another one says `default: true` (`defaults`, the files that do, known
|
|
281
|
-
* once they ran: `dev` and `build` give them, `sync` runs nothing), it
|
|
282
|
-
* refuses `default: true`, naming that file: a second default language is
|
|
283
|
-
* underlined in the editor, and no longer only reported in the console.
|
|
284
|
-
* Each project takes `main` (the commands a language file may translate)
|
|
285
|
-
* and not `shared`, which types `t` from the default file: a language file
|
|
286
|
-
* checked with the `language()` of another would be a false error.
|
|
287
|
-
*/
|
|
288
|
-
export function languageTypedFolders(files, defaults = []) {
|
|
289
|
-
return namedFiles(files).map(({ file, locale }) => {
|
|
290
|
-
const others = defaults.filter(one => one !== file).sort();
|
|
291
|
-
const refusal = others.length === 0
|
|
292
|
-
? ''
|
|
293
|
-
: ` & {
|
|
294
|
-
/** ${others.join(' and ')} already ${others.length === 1 ? 'says' : 'say'} \`default: true\`: only one file of src/messages/ is the default language. Remove it there to make this one the default. */
|
|
295
|
-
default?: false;
|
|
296
|
-
}`;
|
|
297
|
-
return {
|
|
298
|
-
id: `messages.${locale}`,
|
|
299
|
-
folder: file,
|
|
300
|
-
includes: ['main'],
|
|
301
|
-
declarations: `import type { LanguageConfig, LanguageFile, MessageTexts } from '#chapterjs';
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Declares one language of the bot: the one this file is named after.
|
|
305
|
-
* Every handler then receives \`t\`, which gives a text in the language of
|
|
306
|
-
* who will read the message, with its \`{placeholders}\` filled in.
|
|
307
|
-
*/
|
|
308
|
-
export declare function language<const T extends MessageTexts>(
|
|
309
|
-
config: LanguageConfig<T>${refusal}
|
|
310
|
-
): LanguageFile<T>;
|
|
311
|
-
`,
|
|
312
|
-
};
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
/**
|
|
316
|
-
* The augmentation that offers the commands of the project in the
|
|
317
|
-
* `commands` of a language file: the path of each command file, `true`
|
|
318
|
-
* when the language files describe it and `false` when the file has its
|
|
319
|
-
* own `description`, so that the editor only offers the first ones. The
|
|
320
|
-
* path comes from the file name; whether it is described comes from
|
|
321
|
-
* running the file (`dev` and `build` know it, `sync` does not). The
|
|
322
|
-
* command files themselves are never referred to: their `t` is typed from
|
|
323
|
-
* the languages, which would be a circle. Nothing when the project has no
|
|
324
|
-
* command.
|
|
325
|
-
*/
|
|
326
|
-
export function commandsDeclarations(commands) {
|
|
327
|
-
const entries = commands
|
|
328
|
-
.map(({ file, described }) => {
|
|
329
|
-
const inside = file.slice('src/commands/'.length);
|
|
330
|
-
const path = logicalPath(inside).replace(/\.[^./]+$/, '');
|
|
331
|
-
return ` ${JSON.stringify(path)}: ${!described};`;
|
|
332
|
-
})
|
|
333
|
-
.sort();
|
|
334
|
-
if (entries.length === 0)
|
|
335
|
-
return '';
|
|
336
|
-
// A module (`export {}`), so that this augments 'chapterjs' instead of
|
|
337
|
-
// declaring it anew.
|
|
338
|
-
return `export {};
|
|
339
|
-
|
|
340
|
-
declare module 'chapterjs' {
|
|
341
|
-
/** The commands of src/commands/, by the path of their file: true when the language files describe it, false when the file has its own description. */
|
|
342
|
-
export interface ProjectCommands {
|
|
343
|
-
${entries.join('\n')}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
`;
|
|
347
|
-
}
|