@lakutata/i18n 3.0.0-beta.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/LICENSE +23 -0
  2. package/README.md +347 -0
  3. package/dist/cjs/exports/I18n.d.ts +15 -0
  4. package/dist/cjs/exports/I18n.js +45 -0
  5. package/dist/cjs/exports/I18nMiddleware.d.ts +2 -0
  6. package/dist/cjs/exports/I18nMiddleware.js +21 -0
  7. package/dist/cjs/i18n/I18n.d.ts +313 -0
  8. package/dist/cjs/i18n/I18n.js +462 -0
  9. package/dist/cjs/i18n/Translator.d.ts +124 -0
  10. package/dist/cjs/i18n/Translator.js +156 -0
  11. package/dist/cjs/i18n/exceptions/I18nCatalogException.d.ts +15 -0
  12. package/dist/cjs/i18n/exceptions/I18nCatalogException.js +22 -0
  13. package/dist/cjs/i18n/exceptions/I18nMessageSyntaxException.d.ts +14 -0
  14. package/dist/cjs/i18n/exceptions/I18nMessageSyntaxException.js +21 -0
  15. package/dist/cjs/i18n/exceptions/I18nMissingKeyException.d.ts +12 -0
  16. package/dist/cjs/i18n/exceptions/I18nMissingKeyException.js +19 -0
  17. package/dist/cjs/i18n/exceptions/I18nMissingParameterException.d.ts +13 -0
  18. package/dist/cjs/i18n/exceptions/I18nMissingParameterException.js +20 -0
  19. package/dist/cjs/i18n/exceptions/I18nTranslatorException.d.ts +12 -0
  20. package/dist/cjs/i18n/exceptions/I18nTranslatorException.js +19 -0
  21. package/dist/cjs/i18n/interfaces/I18nOptions.d.ts +70 -0
  22. package/dist/cjs/i18n/interfaces/I18nOptions.js +2 -0
  23. package/dist/cjs/i18n/interfaces/ITranslator.d.ts +57 -0
  24. package/dist/cjs/i18n/interfaces/ITranslator.js +2 -0
  25. package/dist/cjs/i18n/lib/Catalog.d.ts +34 -0
  26. package/dist/cjs/i18n/lib/Catalog.js +104 -0
  27. package/dist/cjs/i18n/lib/CatalogTools.d.ts +86 -0
  28. package/dist/cjs/i18n/lib/CatalogTools.js +218 -0
  29. package/dist/cjs/i18n/lib/Locale.d.ts +95 -0
  30. package/dist/cjs/i18n/lib/Locale.js +182 -0
  31. package/dist/cjs/i18n/lib/LocaleTranslator.d.ts +93 -0
  32. package/dist/cjs/i18n/lib/LocaleTranslator.js +105 -0
  33. package/dist/cjs/i18n/lib/MessageFormat.d.ts +144 -0
  34. package/dist/cjs/i18n/lib/MessageFormat.js +372 -0
  35. package/dist/cjs/i18n/middleware/CommanderTranslator.d.ts +71 -0
  36. package/dist/cjs/i18n/middleware/CommanderTranslator.js +69 -0
  37. package/dist/cjs/i18n/middleware/I18nMiddleware.d.ts +126 -0
  38. package/dist/cjs/i18n/middleware/I18nMiddleware.js +210 -0
  39. package/dist/cjs/i18n/types/I18nMessages.d.ts +24 -0
  40. package/dist/cjs/i18n/types/I18nMessages.js +2 -0
  41. package/dist/cjs/package.json +1 -0
  42. package/dist/esm/exports/I18n.js +14 -0
  43. package/dist/esm/exports/I18nMiddleware.js +2 -0
  44. package/dist/esm/i18n/I18n.js +454 -0
  45. package/dist/esm/i18n/Translator.js +153 -0
  46. package/dist/esm/i18n/exceptions/I18nCatalogException.js +18 -0
  47. package/dist/esm/i18n/exceptions/I18nMessageSyntaxException.js +17 -0
  48. package/dist/esm/i18n/exceptions/I18nMissingKeyException.js +15 -0
  49. package/dist/esm/i18n/exceptions/I18nMissingParameterException.js +16 -0
  50. package/dist/esm/i18n/exceptions/I18nTranslatorException.js +15 -0
  51. package/dist/esm/i18n/interfaces/I18nOptions.js +1 -0
  52. package/dist/esm/i18n/interfaces/ITranslator.js +1 -0
  53. package/dist/esm/i18n/lib/Catalog.js +96 -0
  54. package/dist/esm/i18n/lib/CatalogTools.js +213 -0
  55. package/dist/esm/i18n/lib/Locale.js +173 -0
  56. package/dist/esm/i18n/lib/LocaleTranslator.js +101 -0
  57. package/dist/esm/i18n/lib/MessageFormat.js +367 -0
  58. package/dist/esm/i18n/middleware/CommanderTranslator.js +66 -0
  59. package/dist/esm/i18n/middleware/I18nMiddleware.js +206 -0
  60. package/dist/esm/i18n/types/I18nMessages.js +1 -0
  61. package/dist/types/exports/I18n.d.ts +15 -0
  62. package/dist/types/exports/I18nMiddleware.d.ts +2 -0
  63. package/dist/types/i18n/I18n.d.ts +313 -0
  64. package/dist/types/i18n/Translator.d.ts +124 -0
  65. package/dist/types/i18n/exceptions/I18nCatalogException.d.ts +15 -0
  66. package/dist/types/i18n/exceptions/I18nMessageSyntaxException.d.ts +14 -0
  67. package/dist/types/i18n/exceptions/I18nMissingKeyException.d.ts +12 -0
  68. package/dist/types/i18n/exceptions/I18nMissingParameterException.d.ts +13 -0
  69. package/dist/types/i18n/exceptions/I18nTranslatorException.d.ts +12 -0
  70. package/dist/types/i18n/interfaces/I18nOptions.d.ts +70 -0
  71. package/dist/types/i18n/interfaces/ITranslator.d.ts +57 -0
  72. package/dist/types/i18n/lib/Catalog.d.ts +34 -0
  73. package/dist/types/i18n/lib/CatalogTools.d.ts +86 -0
  74. package/dist/types/i18n/lib/Locale.d.ts +95 -0
  75. package/dist/types/i18n/lib/LocaleTranslator.d.ts +93 -0
  76. package/dist/types/i18n/lib/MessageFormat.d.ts +144 -0
  77. package/dist/types/i18n/middleware/CommanderTranslator.d.ts +71 -0
  78. package/dist/types/i18n/middleware/I18nMiddleware.d.ts +126 -0
  79. package/dist/types/i18n/types/I18nMessages.d.ts +24 -0
  80. package/package.json +60 -0
@@ -0,0 +1,372 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseMessage = parseMessage;
4
+ exports.messageArguments = messageArguments;
5
+ exports.formatMessage = formatMessage;
6
+ const I18nMessageSyntaxException_js_1 = require("../exceptions/I18nMessageSyntaxException.js");
7
+ const I18nMissingParameterException_js_1 = require("../exceptions/I18nMissingParameterException.js");
8
+ const NUMBER_STYLES = /^(integer|percent|currency|currency\/[A-Z]{3})$/;
9
+ const DATE_STYLES = new Set(['short', 'medium', 'long', 'full']);
10
+ /**
11
+ * The parser of the messages of ICU MessageFormat: the texts (the apostrophe quoting of ICU), the arguments, the
12
+ * number, date and time arguments, the plural, selectordinal and select choices
13
+ */
14
+ class MessageParser {
15
+ constructor(message, source) {
16
+ this.message = message;
17
+ this.source = source;
18
+ this.offset = 0;
19
+ }
20
+ error(message, offset = this.offset) {
21
+ throw new I18nMessageSyntaxException_js_1.I18nMessageSyntaxException(`${this.source}: ${message} at ${offset + 1}: ${this.message}`);
22
+ }
23
+ parse() {
24
+ const parts = this.parts(false, false);
25
+ if (this.offset < this.message.length)
26
+ this.error('unexpected "}"');
27
+ return parts;
28
+ }
29
+ spaces() {
30
+ while (/\s/.test(this.message[this.offset] ?? ''))
31
+ this.offset++;
32
+ }
33
+ /**
34
+ * The parts of a message, until its end or the closing brace of the option it is in
35
+ * @param nested whether the message is the one of an option
36
+ * @param inPlural whether # is the number of a plural
37
+ */
38
+ parts(nested, inPlural) {
39
+ const parts = [];
40
+ let text = '';
41
+ const flush = () => {
42
+ if (text)
43
+ parts.push(text);
44
+ text = '';
45
+ };
46
+ while (this.offset < this.message.length) {
47
+ const char = this.message[this.offset];
48
+ if (char === '{') {
49
+ flush();
50
+ parts.push(this.argument(inPlural));
51
+ }
52
+ else if (char === '}') {
53
+ if (!nested)
54
+ break;
55
+ flush();
56
+ return parts;
57
+ }
58
+ else if (char === '#' && inPlural) {
59
+ flush();
60
+ parts.push({ type: 'pound' });
61
+ this.offset++;
62
+ }
63
+ else if (char === '\'') {
64
+ text += this.apostrophe(inPlural);
65
+ }
66
+ else {
67
+ text += char;
68
+ this.offset++;
69
+ }
70
+ }
71
+ if (nested)
72
+ this.error('unterminated option, expected "}"');
73
+ flush();
74
+ return parts;
75
+ }
76
+ /**
77
+ * An apostrophe: '' is one apostrophe, '{…}' quotes the syntax characters ({, }, # in a plural), any other one is
78
+ * itself
79
+ * @param inPlural
80
+ */
81
+ apostrophe(inPlural) {
82
+ const next = this.message[this.offset + 1];
83
+ if (next === '\'') {
84
+ this.offset += 2;
85
+ return '\'';
86
+ }
87
+ if (next !== '{' && next !== '}' && !(next === '#' && inPlural)) {
88
+ this.offset++;
89
+ return '\'';
90
+ }
91
+ //Quoted until the next single apostrophe (or the end of the message)
92
+ this.offset++;
93
+ let quoted = '';
94
+ while (this.offset < this.message.length) {
95
+ const char = this.message[this.offset];
96
+ if (char === '\'') {
97
+ if (this.message[this.offset + 1] === '\'') {
98
+ quoted += '\'';
99
+ this.offset += 2;
100
+ continue;
101
+ }
102
+ this.offset++;
103
+ return quoted;
104
+ }
105
+ quoted += char;
106
+ this.offset++;
107
+ }
108
+ return quoted;
109
+ }
110
+ name() {
111
+ const matched = /^[^\s{}#,'=]+/.exec(this.message.slice(this.offset));
112
+ if (!matched)
113
+ this.error('expected the name of an argument');
114
+ this.offset += matched[0].length;
115
+ return matched[0];
116
+ }
117
+ expect(char) {
118
+ this.spaces();
119
+ if (this.message[this.offset] !== char)
120
+ this.error(`expected "${char}"`);
121
+ this.offset++;
122
+ }
123
+ /**
124
+ * An argument: {name}, {name, type}, {name, type, style}, {name, plural|selectordinal|select, options}
125
+ * @param inPlural
126
+ */
127
+ argument(inPlural) {
128
+ const start = this.offset;
129
+ this.offset++;
130
+ this.spaces();
131
+ const name = this.name();
132
+ this.spaces();
133
+ if (this.offset >= this.message.length)
134
+ this.error('unterminated argument', start);
135
+ if (this.message[this.offset] === '}') {
136
+ this.offset++;
137
+ return { type: 'argument', name: name };
138
+ }
139
+ this.expect(',');
140
+ this.spaces();
141
+ const typeStart = this.offset;
142
+ const type = this.name();
143
+ this.spaces();
144
+ switch (type) {
145
+ case 'number':
146
+ case 'date':
147
+ case 'time': {
148
+ if (this.message[this.offset] === '}') {
149
+ this.offset++;
150
+ return { type: type, name: name };
151
+ }
152
+ this.expect(',');
153
+ const styleStart = this.offset;
154
+ const close = this.message.indexOf('}', this.offset);
155
+ if (close < 0)
156
+ this.error('unterminated argument', start);
157
+ const style = this.message.slice(this.offset, close).trim();
158
+ if (type === 'number' ? !NUMBER_STYLES.test(style) : !DATE_STYLES.has(style)) {
159
+ this.error(`unknown ${type} style "${style}" (${type === 'number' ? 'integer, percent, currency, currency/XXX' : 'short, medium, long, full'})`, styleStart);
160
+ }
161
+ this.offset = close + 1;
162
+ return { type: type, name: name, style: style };
163
+ }
164
+ case 'plural':
165
+ case 'selectordinal': {
166
+ this.expect(',');
167
+ this.spaces();
168
+ let offset = 0;
169
+ const matched = /^offset:\s*(\d+)/.exec(this.message.slice(this.offset));
170
+ if (matched) {
171
+ offset = Number(matched[1]);
172
+ this.offset += matched[0].length;
173
+ }
174
+ return { type: type, name: name, offset: offset, options: this.options(true, start) };
175
+ }
176
+ case 'select': {
177
+ this.expect(',');
178
+ return { type: type, name: name, options: this.options(inPlural, start) };
179
+ }
180
+ default:
181
+ this.error(`unknown argument type "${type}" (number, date, time, plural, selectordinal, select)`, typeStart);
182
+ }
183
+ }
184
+ /**
185
+ * The options of a choice, until its closing brace: selector {message}, the "other" one required
186
+ * @param inPlural
187
+ * @param start
188
+ */
189
+ options(inPlural, start) {
190
+ const options = {};
191
+ for (;;) {
192
+ this.spaces();
193
+ if (this.offset >= this.message.length)
194
+ this.error('unterminated argument', start);
195
+ if (this.message[this.offset] === '}') {
196
+ this.offset++;
197
+ break;
198
+ }
199
+ const selectorStart = this.offset;
200
+ const matched = /^=?[^\s{}]+/.exec(this.message.slice(this.offset));
201
+ if (!matched)
202
+ this.error('expected a selector');
203
+ this.offset += matched[0].length;
204
+ if (matched[0] in options)
205
+ this.error(`selector "${matched[0]}" repeated`, selectorStart);
206
+ this.expect('{');
207
+ options[matched[0]] = this.parts(true, inPlural);
208
+ this.offset++;
209
+ }
210
+ if (!('other' in options))
211
+ this.error('the "other" option is required', start);
212
+ return options;
213
+ }
214
+ }
215
+ /**
216
+ * Parse an ICU MessageFormat message into its parts, to inspect it (its arguments: {@link messageArguments}). The
217
+ * syntax: `{name}`; `{name, number}` with the styles `integer`, `percent`, `currency`, `currency/XXX`; `{name, date}`
218
+ * and `{name, time}` with the styles `short`, `medium`, `long`, `full`; `{name, plural, ...}` (with `offset:n`),
219
+ * `{name, selectordinal, ...}` and `{name, select, ...}`, each requiring an `other` option; `#` in a plural; `''` for
220
+ * an apostrophe and `'{...}'` to quote the braces (and `#` in a plural). Other argument types and the skeletons
221
+ * (`::currency/EUR`) are not supported.
222
+ * @param message The message.
223
+ * @param source Where the message comes from (`zh-CN: order.count`), the prefix of the error messages: `'message'` by
224
+ * default.
225
+ * @returns The parts of the message.
226
+ * @throws {I18nMessageSyntaxException} When the message is not valid, with the source and the position.
227
+ * @example
228
+ * ```typescript
229
+ * import {parseMessage, type MessagePart} from 'lakutata/com/i18n'
230
+ *
231
+ * const parts: MessagePart[] = parseMessage('{count, plural, one {# order} other {# orders}} for {customer}')
232
+ * console.log(parts.length) //3
233
+ * ```
234
+ */
235
+ function parseMessage(message, source = 'message') {
236
+ return new MessageParser(message, source).parse();
237
+ }
238
+ /**
239
+ * List the arguments a parsed message uses, nested choices included, with the type of their first use: `argument`,
240
+ * `number`, `date`, `time`, `plural`, `selectordinal` or `select`.
241
+ * @param parts The parts of the message, from {@link parseMessage}.
242
+ * @returns The argument types by argument name, in their order in the message.
243
+ * @example
244
+ * ```typescript
245
+ * import {messageArguments, parseMessage} from 'lakutata/com/i18n'
246
+ *
247
+ * const args: Map<string, string> = messageArguments(parseMessage('{count, plural, other {# orders}} for {customer}'))
248
+ * console.log([...args]) //[['count', 'plural'], ['customer', 'argument']]
249
+ * ```
250
+ */
251
+ function messageArguments(parts) {
252
+ const names = new Map();
253
+ const visit = (items) => {
254
+ for (const part of items) {
255
+ if (typeof part === 'string' || part.type === 'pound')
256
+ continue;
257
+ if (!names.has(part.name))
258
+ names.set(part.name, part.type);
259
+ if ('options' in part)
260
+ Object.values(part.options).forEach(visit);
261
+ }
262
+ };
263
+ visit(parts);
264
+ return names;
265
+ }
266
+ /**
267
+ * The formatters of Intl, created once by their locale and options
268
+ */
269
+ const FORMATTERS = new Map();
270
+ function cachedFormatter(kind, locale, options) {
271
+ const key = `${kind.name}|${locale}|${JSON.stringify(options)}`;
272
+ let formatter = FORMATTERS.get(key);
273
+ if (!formatter) {
274
+ formatter = new kind(locale, options);
275
+ FORMATTERS.set(key, formatter);
276
+ }
277
+ return formatter;
278
+ }
279
+ /**
280
+ * A date of a value: a Date, a timestamp, a date string, or an object giving one (lakutata Time: valueOf)
281
+ * @param value
282
+ */
283
+ function dateOf(value) {
284
+ if (value instanceof Date)
285
+ return value;
286
+ if (value !== null && typeof value === 'object' && typeof value.toDate === 'function')
287
+ return value.toDate();
288
+ return new Date(value);
289
+ }
290
+ /**
291
+ * Format a parsed message
292
+ * @param parts
293
+ * @param params
294
+ * @param context
295
+ * @param source where the message is (its key), for the errors
296
+ */
297
+ function formatMessage(parts, params, context, source = 'message') {
298
+ const format = (items, pound) => {
299
+ let text = '';
300
+ for (const part of items) {
301
+ if (typeof part === 'string') {
302
+ text += part;
303
+ continue;
304
+ }
305
+ if (part.type === 'pound') {
306
+ text += cachedFormatter(Intl.NumberFormat, context.locale, {}).format(pound ?? 0);
307
+ continue;
308
+ }
309
+ const value = params[part.name];
310
+ if (value === undefined) {
311
+ const missing = `${source}: the argument "${part.name}" is missing`;
312
+ if (context.missing === 'throw')
313
+ throw new I18nMissingParameterException_js_1.I18nMissingParameterException(missing);
314
+ context.warn?.(missing);
315
+ text += `{${part.name}}`;
316
+ continue;
317
+ }
318
+ switch (part.type) {
319
+ case 'argument':
320
+ text += typeof value === 'number' || typeof value === 'bigint'
321
+ ? cachedFormatter(Intl.NumberFormat, context.locale, {}).format(value)
322
+ : value instanceof Date ? cachedFormatter(Intl.DateTimeFormat, context.locale, { timeZone: context.timeZone }).format(value) : String(value);
323
+ break;
324
+ case 'number':
325
+ text += cachedFormatter(Intl.NumberFormat, context.locale, numberOptions(part.style, params, context, source)).format(value);
326
+ break;
327
+ case 'date':
328
+ case 'time':
329
+ text += cachedFormatter(Intl.DateTimeFormat, context.locale, { [part.type === 'date' ? 'dateStyle' : 'timeStyle']: part.style ?? 'medium', timeZone: context.timeZone }).format(dateOf(value));
330
+ break;
331
+ case 'plural':
332
+ case 'selectordinal': {
333
+ const count = Number(value);
334
+ const exact = part.options[`=${count}`];
335
+ const rule = cachedFormatter(Intl.PluralRules, context.locale, { type: part.type === 'plural' ? 'cardinal' : 'ordinal' }).select(count - part.offset);
336
+ text += format(exact ?? part.options[rule] ?? part.options.other, count - part.offset);
337
+ break;
338
+ }
339
+ case 'select':
340
+ text += format(part.options[String(value)] ?? part.options.other, pound);
341
+ break;
342
+ }
343
+ }
344
+ return text;
345
+ };
346
+ return format(parts);
347
+ }
348
+ /**
349
+ * The options of Intl.NumberFormat of a style of a number argument
350
+ * @param style
351
+ * @param params
352
+ * @param context
353
+ * @param source
354
+ */
355
+ function numberOptions(style, params, context, source) {
356
+ if (style === 'integer')
357
+ return { maximumFractionDigits: 0 };
358
+ if (style === 'percent')
359
+ return { style: 'percent' };
360
+ if (style?.startsWith('currency')) {
361
+ //The currency of the style, of the parameters, of the configuration
362
+ const currency = style.split('/')[1] ?? params.currency ?? context.currency;
363
+ if (typeof currency === 'string')
364
+ return { style: 'currency', currency: currency };
365
+ const missing = `${source}: the currency is missing (currency/XXX, the "currency" argument, or the currency of the configuration)`;
366
+ if (context.missing === 'throw')
367
+ throw new I18nMissingParameterException_js_1.I18nMissingParameterException(missing);
368
+ //Kept: the number without currency
369
+ context.warn?.(missing);
370
+ }
371
+ return {};
372
+ }
@@ -0,0 +1,71 @@
1
+ import type { Module } from '@lakutata/core';
2
+ import type { CLITranslate } from '@lakutata/entrypoint/adapter/commander';
3
+ /**
4
+ * The options of {@link commanderTranslator}.
5
+ */
6
+ export type CommanderTranslatorOptions = {
7
+ /**
8
+ * The name the {@link I18n} component is registered under.
9
+ * @default 'i18n'
10
+ */
11
+ component?: string;
12
+ /**
13
+ * The prefix of the message keys of the texts: with `'cli'`, the description of the command `greet` is the message
14
+ * `cli.commands.greet.description`; `''` for no prefix.
15
+ * @default 'cli'
16
+ */
17
+ prefix?: string;
18
+ /**
19
+ * The name of an environment variable (`LAKUTATA_LANG`) whose language is preferred to the ones of the environment.
20
+ * @default none
21
+ */
22
+ environment?: string;
23
+ };
24
+ /**
25
+ * Translate the help and the prompts of the Commander CLI adapter with the messages of an {@link I18n} component: give
26
+ * it to the `translate` option of `commanderCLIEntrypoint()`. Each text is the message of its key under the prefix
27
+ * (`cli.description`, `cli.commands.<command>.description`, `cli.commands.<command>.arguments.<name>`,
28
+ * `cli.commands.<command>.options.<property>`, `cli.help.usage`, `cli.help.options`, `cli.help.default`,
29
+ * `cli.prompt.json`…), formatted with the text's parameters (`{value}`); a text without a message keeps its English
30
+ * text. The language is chosen once, when the adapter starts: the environment variable of the `environment` option,
31
+ * the environment ({@link environmentLocales}), then the process's language, negotiated with the component's
32
+ * supported languages. The names of the commands and of the options are never translated.
33
+ * @param options The component, the key prefix and the environment variable.
34
+ * @returns The `translate` function of the adapter, which gets the component from the application's module.
35
+ * @example
36
+ * ```typescript
37
+ * import {Application} from 'lakutata'
38
+ * import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
39
+ * import {commanderCLIEntrypoint} from 'lakutata/adapter/commander'
40
+ * import {CLIAction} from 'lakutata/decorator/ctrl'
41
+ * import {buildI18nOptions, commanderTranslator, I18nMiddleware} from 'lakutata/com/i18n'
42
+ *
43
+ * class GreetController extends Controller {
44
+ * @CLIAction('greet', {description: 'Greet someone'})
45
+ * public async greet(): Promise<string> {
46
+ * return 'Hello'
47
+ * }
48
+ * }
49
+ *
50
+ * Application.run(() => ({
51
+ * id: 'tool.app',
52
+ * name: 'Tool',
53
+ * components: {
54
+ * i18n: buildI18nOptions({
55
+ * messages: {
56
+ * 'zh-CN': {cli: {commands: {greet: {description: '问候某人'}}, help: {usage: '用法:', options: '选项:'}}}
57
+ * }
58
+ * }),
59
+ * entrypoint: buildEntrypoints({
60
+ * controllers: [GreetController],
61
+ * //The controllers' Translator in the same language
62
+ * middlewares: [I18nMiddleware],
63
+ * //LANG=zh_CN.UTF-8 tool --help: the help in Chinese
64
+ * cli: commanderCLIEntrypoint({bin: 'tool', translate: commanderTranslator({environment: 'TOOL_LANG'})})
65
+ * })
66
+ * },
67
+ * bootstrap: ['entrypoint']
68
+ * }))
69
+ * ```
70
+ */
71
+ export declare function commanderTranslator(options?: CommanderTranslatorOptions): (module: Module) => Promise<CLITranslate>;
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.commanderTranslator = commanderTranslator;
4
+ const Locale_js_1 = require("../lib/Locale.js");
5
+ /**
6
+ * The English text of a text, its arguments filled
7
+ * @param text
8
+ */
9
+ function english(text) {
10
+ return text.text.replace(/\{(\w+)\}/g, (all, name) => text.params?.[name] ?? all);
11
+ }
12
+ /**
13
+ * Translate the help and the prompts of the Commander CLI adapter with the messages of an {@link I18n} component: give
14
+ * it to the `translate` option of `commanderCLIEntrypoint()`. Each text is the message of its key under the prefix
15
+ * (`cli.description`, `cli.commands.<command>.description`, `cli.commands.<command>.arguments.<name>`,
16
+ * `cli.commands.<command>.options.<property>`, `cli.help.usage`, `cli.help.options`, `cli.help.default`,
17
+ * `cli.prompt.json`…), formatted with the text's parameters (`{value}`); a text without a message keeps its English
18
+ * text. The language is chosen once, when the adapter starts: the environment variable of the `environment` option,
19
+ * the environment ({@link environmentLocales}), then the process's language, negotiated with the component's
20
+ * supported languages. The names of the commands and of the options are never translated.
21
+ * @param options The component, the key prefix and the environment variable.
22
+ * @returns The `translate` function of the adapter, which gets the component from the application's module.
23
+ * @example
24
+ * ```typescript
25
+ * import {Application} from 'lakutata'
26
+ * import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
27
+ * import {commanderCLIEntrypoint} from 'lakutata/adapter/commander'
28
+ * import {CLIAction} from 'lakutata/decorator/ctrl'
29
+ * import {buildI18nOptions, commanderTranslator, I18nMiddleware} from 'lakutata/com/i18n'
30
+ *
31
+ * class GreetController extends Controller {
32
+ * @CLIAction('greet', {description: 'Greet someone'})
33
+ * public async greet(): Promise<string> {
34
+ * return 'Hello'
35
+ * }
36
+ * }
37
+ *
38
+ * Application.run(() => ({
39
+ * id: 'tool.app',
40
+ * name: 'Tool',
41
+ * components: {
42
+ * i18n: buildI18nOptions({
43
+ * messages: {
44
+ * 'zh-CN': {cli: {commands: {greet: {description: '问候某人'}}, help: {usage: '用法:', options: '选项:'}}}
45
+ * }
46
+ * }),
47
+ * entrypoint: buildEntrypoints({
48
+ * controllers: [GreetController],
49
+ * //The controllers' Translator in the same language
50
+ * middlewares: [I18nMiddleware],
51
+ * //LANG=zh_CN.UTF-8 tool --help: the help in Chinese
52
+ * cli: commanderCLIEntrypoint({bin: 'tool', translate: commanderTranslator({environment: 'TOOL_LANG'})})
53
+ * })
54
+ * },
55
+ * bootstrap: ['entrypoint']
56
+ * }))
57
+ * ```
58
+ */
59
+ function commanderTranslator(options = {}) {
60
+ const prefix = options.prefix ?? 'cli';
61
+ return async (module) => {
62
+ const i18n = await module.getObject(options.component ?? 'i18n');
63
+ const translator = i18n.translator(i18n.negotiate((0, Locale_js_1.processLocales)(options.environment)));
64
+ return (text) => {
65
+ const key = prefix ? `${prefix}.${text.key}` : text.key;
66
+ return translator.has(key) ? translator.t(key, text.params) : english(text);
67
+ };
68
+ };
69
+ }
@@ -0,0 +1,126 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import { Middleware, type ActionInvocation, type BaseContext, type Next } from '@lakutata/entrypoint';
3
+ import type { I18n } from '../I18n.js';
4
+ import { Translator } from '../Translator.js';
5
+ /**
6
+ * The entrypoint middleware choosing the language of each request. Add it to the `middlewares` of
7
+ * `buildEntrypoints()`. For each request, it reads the languages the request asks for ({@link I18nMiddleware.requested}),
8
+ * negotiates them with the supported languages of the {@link I18n} component ({@link I18n.negotiate}, the default
9
+ * language when none matches), and makes the result:
10
+ * - the request's `context.locale`;
11
+ * - the language of the {@link Translator} the request's objects inject;
12
+ * - the language of the validation errors of the request (its input, `@Accept`/`@Return`, the `DTO.validate` calls of
13
+ * its code);
14
+ * - the language of the exceptions it throws: a lakutata `Exception` whose `errno` has a message `errors.<errno>`
15
+ * (`errors.E_ORDER_NOT_FOUND`) gets that message, formatted with the exception's template parameters.
16
+ *
17
+ * The languages are read, preferred first: for HTTP, the `lang` query parameter, the `lang` cookie, then
18
+ * `Accept-Language`; for Socket.IO, the same from the connection's handshake; for the HTTP service adapter and the
19
+ * JSON-RPC adapter over HTTP or WebSocket, the same from the HTTP request (for WebSocket, the request opening the
20
+ * connection); for the command line and the other service transports, the process's environment
21
+ * ({@link environmentLocales}) then the process's language; for the schedules, none. It uses the component registered as `i18n`; a subclass changes the component, the names of the query
22
+ * parameter and of the cookie, or the languages read (`requested()`).
23
+ * @example
24
+ * ```typescript
25
+ * import {type BaseContext} from 'lakutata/com/entrypoint'
26
+ * import {Inject} from 'lakutata/decorator/di'
27
+ * import {I18nMiddleware, type I18n} from 'lakutata/com/i18n'
28
+ *
29
+ * //BuildEntrypoints({middlewares: [LanguageMiddleware], ...}) in place of I18nMiddleware
30
+ * export class LanguageMiddleware extends I18nMiddleware {
31
+ * //The component registered as "lang"
32
+ * @Inject('lang')
33
+ * protected readonly i18n: I18n
34
+ *
35
+ * //?locale=zh-CN
36
+ * protected readonly queryParameter: string = 'locale'
37
+ *
38
+ * protected readonly environmentVariable: string = 'SHOP_LANG'
39
+ *
40
+ * //The language saved by the signed-in user first
41
+ * protected requested(context: BaseContext): string[] {
42
+ * const saved: unknown = context.data.userLanguage
43
+ * return [...(typeof saved === 'string' ? [saved] : []), ...super.requested(context)]
44
+ * }
45
+ * }
46
+ * ```
47
+ */
48
+ export declare class I18nMiddleware extends Middleware {
49
+ /**
50
+ * The {@link I18n} component giving the supported languages and the messages: the object registered as `i18n`.
51
+ */
52
+ protected readonly i18n: I18n;
53
+ /**
54
+ * The name of the query parameter giving the language of an HTTP request (of the HTTP channel, the HTTP service and
55
+ * JSON-RPC adapters) or a Socket.IO connection (`?lang=zh-CN`); a value which is not a language tag is ignored.
56
+ * @default 'lang'
57
+ */
58
+ protected readonly queryParameter: string;
59
+ /**
60
+ * The name of the cookie giving the language of an HTTP request (of the HTTP channel, the HTTP service and JSON-RPC
61
+ * adapters) or a Socket.IO connection; a value which is not a language tag is ignored.
62
+ * @default 'lang'
63
+ */
64
+ protected readonly cookie: string;
65
+ /**
66
+ * The name of an environment variable (`SHOP_LANG`) whose language is preferred to the ones of the environment, for
67
+ * the command line and the service transports reading the process's languages.
68
+ * @default none
69
+ */
70
+ protected readonly environmentVariable?: string;
71
+ /**
72
+ * Negotiate the request's language, set `context.locale`, create the request's {@link Translator}, then run the
73
+ * next middlewares and the action with the validations in that language, translating the exception they throw.
74
+ * @param invocation The action invocation: its context and its container.
75
+ * @param next Runs the rest of the chain.
76
+ * @returns The result of the rest of the chain; an exception it throws is rethrown, its message translated by
77
+ * {@link I18nMiddleware.translate}.
78
+ */
79
+ handle(invocation: ActionInvocation, next: Next): Promise<unknown>;
80
+ /**
81
+ * Translate an exception thrown by a request into the request's language. A lakutata `Exception` with an `errno`
82
+ * whose key `errors.<errno>` has a message (`errors.E_ORDER_NOT_FOUND`) gets that message as its `message` and
83
+ * `errMsg`, formatted with the parameters of its template (`{id}` for named ones, `{0}`, `{1}` for the ones given one
84
+ * by one); its `errno` and its other properties are kept. The other errors, the exceptions without such a message,
85
+ * and the ones whose message cannot be formatted (a process warning `I18nWarning` is then emitted) are returned
86
+ * unchanged.
87
+ * @param error The thrown value.
88
+ * @param translator The request's translator.
89
+ * @returns The same value, its message replaced when it is translated.
90
+ */
91
+ protected translate(error: unknown, translator: Translator): unknown;
92
+ /**
93
+ * Read the languages a request asks for, preferred first:
94
+ * - HTTP: the query parameter, the cookie, then `Accept-Language`;
95
+ * - Socket.IO: the query parameter, the cookie and `Accept-Language` of the connection's handshake;
96
+ * - the HTTP service adapter, and the JSON-RPC adapter over HTTP or WebSocket: the query parameter, the cookie and
97
+ * `Accept-Language` of the HTTP request (for WebSocket, the request opening the connection);
98
+ * - the command line and the other service transports (Electron IPC, JSON-RPC over stdio): the environment variable
99
+ * of `environmentVariable`, the environment (`LANGUAGE`, `LC_ALL`, `LC_MESSAGES`, `LANG`), then the process's
100
+ * language;
101
+ * - the schedules and the other contexts: none (the default language is chosen).
102
+ * @param context The request's context.
103
+ * @returns The requested language tags, preferred first.
104
+ */
105
+ protected requested(context: BaseContext): string[];
106
+ /**
107
+ * Read the languages of an HTTP request: its query parameter, its cookie, then its `Accept-Language`.
108
+ * @param request The Node.js HTTP request.
109
+ * @returns The language tags, preferred first.
110
+ */
111
+ protected fromRequest(request: IncomingMessage): string[];
112
+ /**
113
+ * Read the languages of a query parameter, the cookie and `Accept-Language`, in that order; the query and cookie
114
+ * values which are not language tags are skipped.
115
+ * @param query The value of the query parameter; ignored unless it is a string.
116
+ * @param headers The request headers, by lower-case name; for a repeated header, its first value is read.
117
+ * @returns The language tags, preferred first.
118
+ */
119
+ protected fromHeaders(query: unknown, headers: Record<string, string | string[] | undefined>): string[];
120
+ /**
121
+ * Read the value of the language cookie (named by `cookie`) in a `Cookie` header, URI-decoded.
122
+ * @param header The value of the `Cookie` header.
123
+ * @returns The value of the first cookie of that name; `undefined` without it, or when it cannot be decoded.
124
+ */
125
+ protected cookieValue(header: string | undefined): string | undefined;
126
+ }