@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,454 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ import path from 'node:path';
11
+ import { Component, DTO, loadValidationMessages, registerValidationMessages } from '@lakutata/core';
12
+ import { Configurable } from '@lakutata/core/decorator/di';
13
+ import { flattenMessages, loadDirectory, mergeMessages } from './lib/Catalog.js';
14
+ import { fallbackChain, negotiateLocale, normalizeLocale } from './lib/Locale.js';
15
+ import { formatMessage, parseMessage } from './lib/MessageFormat.js';
16
+ import { LocaleTranslator } from './lib/LocaleTranslator.js';
17
+ import { I18nCatalogException } from './exceptions/I18nCatalogException.js';
18
+ import { I18nMissingKeyException } from './exceptions/I18nMissingKeyException.js';
19
+ /**
20
+ * Build the component options of an {@link I18n} component, to register it in the `components` of an application.
21
+ * Name it `i18n`: the {@link I18nMiddleware} and {@link commanderTranslator} use the component of that name by
22
+ * default. The options are validated when the component is created, not here.
23
+ * @param options Where the messages come from (`directory`, `messages`), the default language, the fallbacks, the
24
+ * behavior on a missing message, the currency and time zone of the formatted arguments, the validation messages.
25
+ * Without options, the component has no messages and its default language is `en`.
26
+ * @returns The component options (`{class: I18n, ...options}`).
27
+ * @example
28
+ * ```typescript
29
+ * import {Application} from 'lakutata'
30
+ * import {buildI18nOptions, type I18n} from 'lakutata/com/i18n'
31
+ *
32
+ * Application
33
+ * .alias({'@locales': '/srv/shop/locales'})
34
+ * .run(() => ({
35
+ * id: 'shop.app',
36
+ * name: 'Shop',
37
+ * components: {
38
+ * i18n: buildI18nOptions({
39
+ * //locales/en.json, locales/zh-CN.json, or a directory per language
40
+ * directory: '@locales',
41
+ * defaultLocale: 'en',
42
+ * //A message missing in zh-TW is looked up in zh-CN, then zh, then en
43
+ * fallback: {'zh-TW': 'zh-CN'},
44
+ * currency: 'CNY',
45
+ * timeZone: 'Asia/Shanghai'
46
+ * })
47
+ * }
48
+ * }))
49
+ * .onLaunched(async (app: Application): Promise<void> => {
50
+ * const i18n: I18n = await app.getObject('i18n')
51
+ * console.log(i18n.getLocales())
52
+ * })
53
+ * ```
54
+ */
55
+ export const buildI18nOptions = (options) => ({
56
+ class: I18n,
57
+ directory: options?.directory,
58
+ locales: options?.locales,
59
+ defaultLocale: options?.defaultLocale,
60
+ fallback: options?.fallback,
61
+ messages: options?.messages,
62
+ missing: options?.missing,
63
+ currency: options?.currency,
64
+ timeZone: options?.timeZone,
65
+ validationMessages: options?.validationMessages
66
+ });
67
+ /**
68
+ * The internationalization component: the application's messages in each language, by key, written in ICU
69
+ * MessageFormat (arguments, numbers, currencies, dates, plurals, selects), formatted in their language by the `Intl`
70
+ * of Node.js. Register it in the `components` of an application with {@link buildI18nOptions}, named `i18n`, and get
71
+ * it with `getObject()` or `@Inject('i18n')`. In a request, inject the {@link Translator} instead (the
72
+ * {@link I18nMiddleware} gives it the request's language); out of a request, call {@link I18n.t} with a language or
73
+ * use {@link I18n.translator}.
74
+ *
75
+ * When it is created, the component loads the messages of its `directory` option, then adds the ones of its `messages`
76
+ * option, and registers the validation messages of its languages. A message missing in a language is looked up in the
77
+ * languages it falls back to: its `fallback` languages, its less specific tags (`zh-Hant-TW`, `zh-Hant`, `zh`), then
78
+ * the default language (see {@link fallbackChain}). A message is always formatted in the language asked, even when it
79
+ * comes from a fallback. A key found nowhere throws {@link I18nMissingKeyException}, or gives the key itself with the
80
+ * `missing: 'key'` option (the default when `NODE_ENV` is `production`).
81
+ * @example
82
+ * ```typescript
83
+ * import {Application} from 'lakutata'
84
+ * import {buildI18nOptions, type I18n} from 'lakutata/com/i18n'
85
+ *
86
+ * Application
87
+ * .run(() => ({
88
+ * id: 'shop.app',
89
+ * name: 'Shop',
90
+ * components: {
91
+ * i18n: buildI18nOptions({
92
+ * messages: {
93
+ * 'en': {order: {count: '{count, plural, =0 {No orders} one {# order} other {# orders}}', total: 'Total: {price, number, currency}'}},
94
+ * 'zh-CN': {order: {count: '{count, plural, =0 {暂无订单} other {# 个订单}}'}}
95
+ * },
96
+ * currency: 'CNY'
97
+ * })
98
+ * }
99
+ * }))
100
+ * .onLaunched(async (app: Application): Promise<void> => {
101
+ * const i18n: I18n = await app.getObject('i18n')
102
+ * console.log(i18n.t('order.count', {count: 1200})) //1,200 orders
103
+ * console.log(i18n.t('order.count', {count: 0}, 'zh-CN')) //暂无订单
104
+ * //Missing in zh-CN: the English message, formatted in Chinese
105
+ * console.log(i18n.t('order.total', {price: 9.5}, 'zh-CN')) //Total: ¥9.50
106
+ * console.log(i18n.negotiate(['zh-TW', 'en'])) //zh-CN
107
+ * })
108
+ * ```
109
+ */
110
+ export class I18n extends Component {
111
+ #catalogs = new Map();
112
+ #parsed = new Map();
113
+ #warned = new Set();
114
+ #builtIns = new Map();
115
+ #default = 'en';
116
+ #supported = [];
117
+ #fallbacks = {};
118
+ /**
119
+ * Load the messages (the directories, then the `messages` option), compute the supported languages and register
120
+ * the validation messages; called when the component is created.
121
+ * @throws {I18nCatalogException} When a directory, a file, a key or a language tag of the options is invalid.
122
+ */
123
+ async init() {
124
+ this.#default = this.locale(this.defaultLocale, 'defaultLocale');
125
+ for (const [locale, fallbacks] of Object.entries(this.fallback ?? {})) {
126
+ this.#fallbacks[this.locale(locale, 'fallback')] = (Array.isArray(fallbacks) ? fallbacks : [fallbacks]).map((fallback) => this.locale(fallback, 'fallback'));
127
+ }
128
+ const directories = this.directory === undefined ? [] : Array.isArray(this.directory) ? this.directory : [this.directory];
129
+ for (const directory of directories)
130
+ await loadDirectory(this.#catalogs, this.resolvePath(directory));
131
+ for (const [locale, messages] of Object.entries(this.messages ?? {}))
132
+ this.merge(locale, messages);
133
+ this.#supported = this.locales
134
+ ? this.locales.map((locale) => this.locale(locale, 'locales'))
135
+ : this.discovered();
136
+ await this.registerValidationMessages();
137
+ }
138
+ /**
139
+ * Give the supported languages without the `locales` option: the default language, then the languages having
140
+ * messages, in alphabetical order.
141
+ * @returns The canonical tags.
142
+ */
143
+ discovered() {
144
+ return [...new Set([this.#default, ...[...this.#catalogs.keys()].sort()])];
145
+ }
146
+ /**
147
+ * Register the validation error messages of languages, so that the validations run in that language give them. A
148
+ * language's messages are layered from the end of its fallback chain to itself: for each language of the chain,
149
+ * its built-in messages (unless `validationMessages` is `false`), then the application's `validation.<code>`
150
+ * messages (`validation.any.required`), the later ones replacing the earlier ones. Called by `init()` for all the
151
+ * languages, and by {@link I18n.addMessages} for the languages its messages change.
152
+ * @param locales The canonical tags of the languages to register: by default, each supported language and each
153
+ * language having messages.
154
+ * @throws {SchemaDefinitionException} When a `validation.*` message is not a valid template.
155
+ */
156
+ async registerValidationMessages(locales = new Set([...this.#supported, ...this.#catalogs.keys()])) {
157
+ const prefix = 'validation.';
158
+ //The built-in messages loaded once for the component's lifetime
159
+ const builtIns = this.#builtIns;
160
+ for (const locale of locales) {
161
+ const messages = {};
162
+ //The languages it falls back to first, its own messages replacing theirs
163
+ for (const tag of fallbackChain(locale, this.#fallbacks, this.#default).reverse()) {
164
+ if (this.validationMessages) {
165
+ if (!builtIns.has(tag))
166
+ builtIns.set(tag, await loadValidationMessages(tag));
167
+ Object.assign(messages, builtIns.get(tag));
168
+ }
169
+ this.#catalogs.get(tag)?.forEach((message, key) => {
170
+ if (key.startsWith(prefix))
171
+ messages[key.slice(prefix.length)] = message;
172
+ });
173
+ }
174
+ if (Object.keys(messages).length)
175
+ registerValidationMessages(locale, messages);
176
+ }
177
+ }
178
+ /**
179
+ * Normalize a language tag given in the configuration.
180
+ * @param tag The language tag.
181
+ * @param option The name of the option giving it, for the error message.
182
+ * @returns The canonical tag.
183
+ * @throws {I18nCatalogException} When the tag is not a language tag.
184
+ */
185
+ locale(tag, option) {
186
+ const locale = normalizeLocale(tag);
187
+ if (!locale)
188
+ throw new I18nCatalogException(`${option}: "${tag}" is not the tag of a language (en, zh-CN, zh-Hant-TW)`);
189
+ return locale;
190
+ }
191
+ /**
192
+ * Resolve a directory of the configuration to an absolute path: an alias (`@locales/en`) by the application
193
+ * holding the component, any other path from the working directory.
194
+ * @param directory The path or the alias.
195
+ * @returns The absolute path.
196
+ * @throws {I18nCatalogException} When an alias is given and no enclosing module resolves aliases.
197
+ */
198
+ resolvePath(directory) {
199
+ if (!directory.startsWith('@'))
200
+ return path.resolve(directory);
201
+ //The application holding the aliases: the module of the module, up to the one with the aliases
202
+ let module = this.getModule();
203
+ while (module && typeof module.alias?.resolve !== 'function')
204
+ module = module.getModule?.();
205
+ if (!module)
206
+ throw new I18nCatalogException(`directory: the alias of "${directory}" is resolved by an application`);
207
+ return path.resolve(module.alias.resolve(directory));
208
+ }
209
+ /**
210
+ * Tell what a missing message or argument does: the `missing` option, else `'key'` when `NODE_ENV` is
211
+ * `production` and `'throw'` otherwise (read at each call).
212
+ * @returns `'throw'` or `'key'`.
213
+ */
214
+ missingMode() {
215
+ return this.missing ?? (process.env.NODE_ENV === 'production' ? 'key' : 'throw');
216
+ }
217
+ /**
218
+ * Emit a process warning of type `I18nWarning`, once per distinct text for the component's lifetime.
219
+ * @param warning The text of the warning.
220
+ */
221
+ warn(warning) {
222
+ if (this.#warned.has(warning))
223
+ return;
224
+ this.#warned.add(warning);
225
+ process.emitWarning(warning, 'I18nWarning');
226
+ }
227
+ /**
228
+ * Give the formatting settings of a language: the language, the configured currency and time zone, and what a
229
+ * missing argument or currency does (thrown, or kept with a warning emitted once by {@link I18n.warn}).
230
+ * @param locale The canonical language tag the message is formatted in.
231
+ * @returns The formatting settings.
232
+ */
233
+ context(locale) {
234
+ return {
235
+ locale: locale,
236
+ currency: this.currency,
237
+ timeZone: this.timeZone,
238
+ missing: this.missingMode() === 'throw' ? 'throw' : 'keep',
239
+ warn: (warning) => this.warn(warning)
240
+ };
241
+ }
242
+ /**
243
+ * Give the supported languages: the `locales` option, or the default language then the languages having messages
244
+ * (the ones added by {@link I18n.addMessages} included), in alphabetical order.
245
+ * @returns A copy of the canonical tags.
246
+ */
247
+ getLocales() {
248
+ return [...this.#supported];
249
+ }
250
+ /**
251
+ * Give the default language.
252
+ * @returns The canonical tag of the `defaultLocale` option (`en` by default).
253
+ */
254
+ getDefaultLocale() {
255
+ return this.#default;
256
+ }
257
+ /**
258
+ * Give the time zone the dates and the times are formatted in.
259
+ * @returns The `timeZone` option; `undefined` when it is not set (the process's time zone is then used).
260
+ */
261
+ getTimeZone() {
262
+ return this.timeZone;
263
+ }
264
+ /**
265
+ * List the keys a language has messages for: its own keys, not the ones of the languages it falls back to.
266
+ * @param locale The language tag (normalized; `zh_CN` is `zh-CN`).
267
+ * @returns The full keys (`order.count`), in their loading order; empty for a language without messages.
268
+ */
269
+ getKeys(locale) {
270
+ return [...(this.#catalogs.get(normalizeLocale(locale) ?? locale)?.keys() ?? [])];
271
+ }
272
+ /**
273
+ * Add messages to a language at run time, replacing the messages of the same keys; {@link I18n.t} gives them as
274
+ * soon as it returns. As for the messages loaded at the creation, a new language is added to the supported
275
+ * languages ({@link I18n.getLocales}, {@link I18n.negotiate}) unless the `locales` option lists them, and the
276
+ * validation messages of the new language (its built-in ones) and of the `validation.*` messages added are
277
+ * registered, for the language and the languages falling back to it.
278
+ * @param locale The language tag (normalized).
279
+ * @param messages The messages, nested by the parts of their keys (`{order: {count: '...'}}`).
280
+ * @returns A promise resolved once the validation messages are registered (the built-in ones of a language are
281
+ * loaded on demand); it rejects with a `SchemaDefinitionException` when a `validation.*` message is not a valid
282
+ * template.
283
+ * @throws {I18nCatalogException} When the tag is not a language tag, or a key or a value is invalid: thrown by the
284
+ * call, nothing added.
285
+ * @example
286
+ * ```typescript
287
+ * import {type I18n} from 'lakutata/com/i18n'
288
+ *
289
+ * declare const i18n: I18n
290
+ *
291
+ * await i18n.addMessages('fr', {order: {created: 'Commande créée pour {customer}'}, validation: {any: {required: '{{#label}} est obligatoire'}}})
292
+ * console.log(i18n.negotiate(['fr-CA'])) //fr
293
+ * console.log(i18n.t('order.created', {customer: 'Ada'}, 'fr')) //Commande créée pour Ada
294
+ * ```
295
+ */
296
+ addMessages(locale, messages) {
297
+ const tag = this.locale(locale, 'messages');
298
+ const known = this.#catalogs.has(tag);
299
+ const flattened = this.merge(tag, messages);
300
+ if (!this.locales)
301
+ this.#supported = this.discovered();
302
+ //The validation messages changed: the language's built-in ones when it is new, its validation.* messages
303
+ if (known && ![...flattened.keys()].some((key) => key.startsWith('validation.')))
304
+ return Promise.resolve();
305
+ const changed = [...new Set([...this.#supported, ...this.#catalogs.keys()])].filter((candidate) => fallbackChain(candidate, this.#fallbacks, this.#default).includes(tag));
306
+ return this.registerValidationMessages(changed);
307
+ }
308
+ /**
309
+ * Merge messages into the catalog of a language, its cached parsed messages dropped.
310
+ * @param locale The language tag.
311
+ * @param messages The nested messages.
312
+ * @returns The messages merged, by full key.
313
+ * @throws {I18nCatalogException} When the tag is not a language tag, or a key or a value is invalid.
314
+ */
315
+ merge(locale, messages) {
316
+ const tag = this.locale(locale, 'messages');
317
+ const flattened = flattenMessages(messages, `messages of ${tag}`);
318
+ mergeMessages(this.#catalogs, tag, flattened);
319
+ //The messages replaced are parsed again
320
+ for (const key of this.#parsed.keys())
321
+ if (key.startsWith(`${tag}\u0000`))
322
+ this.#parsed.delete(key);
323
+ return flattened;
324
+ }
325
+ /**
326
+ * Choose the supported language for the requested ones (see {@link negotiateLocale}): for each requested language
327
+ * in turn, the same tag, then its language alone (`en-GB` gives `en`), then another tag of its language (`pt-PT`
328
+ * gives `pt-BR`).
329
+ * @param requested The requested language tags, preferred first; the invalid ones are skipped.
330
+ * @returns A supported language, or the default language when none matches.
331
+ */
332
+ negotiate(requested) {
333
+ return negotiateLocale(requested, this.#supported, this.#default);
334
+ }
335
+ /**
336
+ * Find the message of a key in a language or the languages it falls back to, parsed once and cached.
337
+ * @param key The key of the message.
338
+ * @param locale The canonical language tag.
339
+ * @returns The parsed message and the language it was found in; `undefined` when no language of the chain has it.
340
+ * @throws {I18nMessageSyntaxException} When the message found is not valid ICU MessageFormat.
341
+ */
342
+ message(key, locale) {
343
+ for (const tag of fallbackChain(locale, this.#fallbacks, this.#default)) {
344
+ const message = this.#catalogs.get(tag)?.get(key);
345
+ if (message === undefined)
346
+ continue;
347
+ const cacheKey = `${tag}\u0000${key}`;
348
+ let parts = this.#parsed.get(cacheKey);
349
+ if (!parts)
350
+ this.#parsed.set(cacheKey, parts = parseMessage(message, `${tag}: ${key}`));
351
+ return { parts: parts, locale: tag };
352
+ }
353
+ return undefined;
354
+ }
355
+ /**
356
+ * Tell whether a key has a message in a language or in the languages it falls back to.
357
+ * @param key The key of the message.
358
+ * @param locale The language tag: the default language by default, and when it is not a language tag.
359
+ * @returns `true` when {@link I18n.t} finds a message for the key in that language.
360
+ */
361
+ has(key, locale = this.#default) {
362
+ return fallbackChain(normalizeLocale(locale) ?? this.#default, this.#fallbacks, this.#default).some((tag) => !!this.#catalogs.get(tag)?.has(key));
363
+ }
364
+ /**
365
+ * Give the message of a key in a language, formatted with its arguments. The language is not negotiated: the
366
+ * message is looked up in the language given, then in the languages it falls back to (see {@link I18n}), and is
367
+ * formatted in the language given (its numbers, dates and plural rules) even when it comes from a fallback.
368
+ * @param key The key of the message (`order.count`).
369
+ * @param params The arguments of the message, by their names: the numbers and the bigints are formatted in the
370
+ * language, the `Date` objects as dates. A `currency` argument gives the currency of `{price, number, currency}`.
371
+ * @param locale The language tag: the default language by default, and when it is not a language tag.
372
+ * @returns The formatted message. With `missing: 'key'`, the key itself when no message has it, `{name}` in place
373
+ * of a missing argument, and a plain number for a currency argument without a currency, each with a warning
374
+ * emitted once (per key, or per message and argument).
375
+ * @throws {I18nMissingKeyException} When no message has the key and the `missing` mode is `'throw'`.
376
+ * @throws {I18nMissingParameterException} When an argument is missing, or a currency argument has no currency, and
377
+ * the `missing` mode is `'throw'`.
378
+ * @throws {I18nMessageSyntaxException} When the message is not valid ICU MessageFormat.
379
+ */
380
+ t(key, params, locale = this.#default) {
381
+ const tag = normalizeLocale(locale) ?? this.#default;
382
+ const message = this.message(key, tag);
383
+ if (!message) {
384
+ const missing = `No message "${key}" in ${fallbackChain(tag, this.#fallbacks, this.#default).join(', ')}`;
385
+ if (this.missingMode() === 'throw')
386
+ throw new I18nMissingKeyException(missing);
387
+ this.warn(missing);
388
+ return key;
389
+ }
390
+ //Formatted in the language asked, its numbers and plurals too
391
+ return formatMessage(message.parts, (params ?? {}), this.context(tag), `${message.locale}: ${key}`);
392
+ }
393
+ /**
394
+ * Format an ICU MessageFormat message which is not in the catalogs (parsed at each call).
395
+ * @param message The message (`Hello, {name}`).
396
+ * @param params The arguments of the message, by their names.
397
+ * @param locale The language tag it is formatted in: the default language by default, and when it is not a
398
+ * language tag.
399
+ * @returns The formatted message. With `missing: 'key'`, `{name}` in place of a missing argument and a plain
400
+ * number for a currency argument without a currency, each with a warning emitted once per message and argument.
401
+ * @throws {I18nMessageSyntaxException} When the message is not valid ICU MessageFormat.
402
+ * @throws {I18nMissingParameterException} When an argument is missing, or a currency argument has no currency, and
403
+ * the `missing` mode is `'throw'`.
404
+ */
405
+ format(message, params, locale = this.#default) {
406
+ const tag = normalizeLocale(locale) ?? this.#default;
407
+ return formatMessage(parseMessage(message), params ?? {}, this.context(tag), `message "${message}"`);
408
+ }
409
+ /**
410
+ * Create a translator of a language, which gives its messages without the language at each call. The language is
411
+ * not negotiated: it is used as given (its messages falling back as in {@link I18n.t}).
412
+ * @param locale The language tag (normalized): the default language when it is not a language tag.
413
+ * @returns The translator, a {@link LocaleTranslator}.
414
+ */
415
+ translator(locale) {
416
+ return new LocaleTranslator(this, normalizeLocale(locale) ?? this.#default);
417
+ }
418
+ }
419
+ __decorate([
420
+ Configurable(DTO.Alternatives(DTO.String(), DTO.Array(DTO.String())).optional()),
421
+ __metadata("design:type", Object)
422
+ ], I18n.prototype, "directory", void 0);
423
+ __decorate([
424
+ Configurable(DTO.Array(DTO.String()).optional()),
425
+ __metadata("design:type", Array)
426
+ ], I18n.prototype, "locales", void 0);
427
+ __decorate([
428
+ Configurable(DTO.String().optional().default('en')),
429
+ __metadata("design:type", String)
430
+ ], I18n.prototype, "defaultLocale", void 0);
431
+ __decorate([
432
+ Configurable(DTO.Object().pattern(DTO.String(), DTO.Alternatives(DTO.String(), DTO.Array(DTO.String()))).optional()),
433
+ __metadata("design:type", Object)
434
+ ], I18n.prototype, "fallback", void 0);
435
+ __decorate([
436
+ Configurable(DTO.Object().pattern(DTO.String(), DTO.Object().unknown()).optional()),
437
+ __metadata("design:type", Object)
438
+ ], I18n.prototype, "messages", void 0);
439
+ __decorate([
440
+ Configurable(DTO.String().valid('throw', 'key').optional()),
441
+ __metadata("design:type", String)
442
+ ], I18n.prototype, "missing", void 0);
443
+ __decorate([
444
+ Configurable(DTO.String().optional()),
445
+ __metadata("design:type", String)
446
+ ], I18n.prototype, "currency", void 0);
447
+ __decorate([
448
+ Configurable(DTO.String().optional()),
449
+ __metadata("design:type", String)
450
+ ], I18n.prototype, "timeZone", void 0);
451
+ __decorate([
452
+ Configurable(DTO.Boolean().optional().default(true)),
453
+ __metadata("design:type", Boolean)
454
+ ], I18n.prototype, "validationMessages", void 0);
@@ -0,0 +1,153 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ import { DTO, Provider } from '@lakutata/core';
11
+ import { Autoload, Configurable, Scoped } from '@lakutata/core/decorator/di';
12
+ import { I18nTranslatorException } from './exceptions/I18nTranslatorException.js';
13
+ /**
14
+ * The messages in the language of the current request. With the {@link I18nMiddleware} on the entrypoint, each request
15
+ * gets one `Translator` of its negotiated language, shared by the objects of the request: the controller, the
16
+ * `@Scoped` providers and the access control rules inject it with `@Inject(Translator)`. It is a scoped provider,
17
+ * autoloaded: it needs no registration. Without the middleware (or out of a request), its methods throw
18
+ * {@link I18nTranslatorException}; use {@link I18n.translator} there.
19
+ * @example
20
+ * ```typescript
21
+ * import {Application} from 'lakutata'
22
+ * import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
23
+ * import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
24
+ * import {CLIAction, GET} from 'lakutata/decorator/ctrl'
25
+ * import {Inject} from 'lakutata/decorator/di'
26
+ * import {buildI18nOptions, I18nMiddleware, Translator} from 'lakutata/com/i18n'
27
+ *
28
+ * class OrderController extends Controller {
29
+ * @Inject(Translator)
30
+ * protected readonly translator: Translator
31
+ *
32
+ * //GET /orders/count?lang=zh-CN, or the lang cookie, or Accept-Language
33
+ * @GET('/orders/count')
34
+ * @CLIAction('count-orders')
35
+ * public async count(): Promise<string> {
36
+ * return `${this.translator.locale}: ${this.translator.t('order.count', {count: 3})}`
37
+ * }
38
+ * }
39
+ *
40
+ * Application.run(() => ({
41
+ * id: 'shop.app',
42
+ * name: 'Shop',
43
+ * components: {
44
+ * i18n: buildI18nOptions({
45
+ * messages: {
46
+ * 'en': {order: {count: '{count, plural, one {# order} other {# orders}}'}},
47
+ * 'zh-CN': {order: {count: '{count} 个订单'}}
48
+ * }
49
+ * }),
50
+ * entrypoint: buildEntrypoints({
51
+ * controllers: [OrderController],
52
+ * middlewares: [I18nMiddleware],
53
+ * http: expressHTTPEntrypoint({port: 3000})
54
+ * })
55
+ * },
56
+ * bootstrap: ['entrypoint']
57
+ * }))
58
+ * ```
59
+ */
60
+ let Translator = class Translator extends Provider {
61
+ #translator;
62
+ /**
63
+ * Create the translator of the language given by the middleware, when it gave one.
64
+ */
65
+ async init() {
66
+ if (this.i18n && this.locale)
67
+ this.#translator = this.i18n.translator(this.locale);
68
+ }
69
+ /**
70
+ * The translator of the request's language.
71
+ * @throws {I18nTranslatorException} When the middleware did not give the language (no {@link I18nMiddleware} on
72
+ * the entrypoint, or out of a request).
73
+ */
74
+ get translator() {
75
+ if (!this.#translator)
76
+ throw new I18nTranslatorException('The translator of a request is given by the I18nMiddleware of its entrypoint: buildEntrypoints({middlewares: [I18nMiddleware]})');
77
+ return this.#translator;
78
+ }
79
+ /**
80
+ * Give the message of a key in the request's language, formatted with its arguments (see {@link I18n.t}).
81
+ * @param key The key of the message (`order.count`).
82
+ * @param params The arguments of the message, by their names.
83
+ * @returns The formatted message. With `missing: 'key'`, the key itself when it is missing, `{name}` in place of a
84
+ * missing argument, a plain number for a currency argument without a currency (each with a warning, once).
85
+ * @throws {I18nTranslatorException} When the entrypoint has no {@link I18nMiddleware}, or out of a request.
86
+ * @throws {I18nMissingKeyException} When no message has the key and the `missing` option is `'throw'`.
87
+ * @throws {I18nMissingParameterException} When an argument is missing, or a currency argument has no currency, and
88
+ * the `missing` option is `'throw'`.
89
+ * @throws {I18nMessageSyntaxException} When the message is not valid ICU MessageFormat.
90
+ */
91
+ t(key, params) {
92
+ return this.translator.t(key, params);
93
+ }
94
+ /**
95
+ * Tell whether a key has a message in the request's language or in the languages it falls back to.
96
+ * @param key The key of the message.
97
+ * @returns `true` when {@link Translator.t} finds a message for the key.
98
+ * @throws {I18nTranslatorException} When the entrypoint has no {@link I18nMiddleware}, or out of a request.
99
+ */
100
+ has(key) {
101
+ return this.translator.has(key);
102
+ }
103
+ /**
104
+ * Format an ICU MessageFormat message which is not in the catalogs, in the request's language.
105
+ * @param message The message (`Hello, {name}`).
106
+ * @param params The arguments of the message, by their names.
107
+ * @returns The formatted message. With `missing: 'key'`, `{name}` in place of a missing argument, a plain number
108
+ * for a currency argument without a currency (each with a warning, once).
109
+ * @throws {I18nTranslatorException} When the entrypoint has no {@link I18nMiddleware}, or out of a request.
110
+ * @throws {I18nMessageSyntaxException} When the message is not valid ICU MessageFormat.
111
+ * @throws {I18nMissingParameterException} When an argument is missing, or a currency argument has no currency, and
112
+ * the `missing` option is `'throw'`.
113
+ */
114
+ format(message, params) {
115
+ return this.translator.format(message, params);
116
+ }
117
+ /**
118
+ * Format a number in the request's language (`1234.5` gives `1,234.5` in English, `1.234,5` in German).
119
+ * @param value The number.
120
+ * @param options The options of `Intl.NumberFormat` (`{style: 'currency', currency: 'EUR'}`); the component's
121
+ * `currency` option is not applied here.
122
+ * @returns The formatted number.
123
+ * @throws {I18nTranslatorException} When the entrypoint has no {@link I18nMiddleware}, or out of a request.
124
+ */
125
+ number(value, options) {
126
+ return this.translator.number(value, options);
127
+ }
128
+ /**
129
+ * Format a date (and its time when the options ask for it) in the request's language, in the component's
130
+ * `timeZone` (the process's one without it).
131
+ * @param value The date, or a timestamp in milliseconds.
132
+ * @param options The options of `Intl.DateTimeFormat` (`{dateStyle: 'long'}`); their `timeZone` replaces the
133
+ * component's one.
134
+ * @returns The formatted date.
135
+ * @throws {I18nTranslatorException} When the entrypoint has no {@link I18nMiddleware}, or out of a request.
136
+ */
137
+ date(value, options) {
138
+ return this.translator.date(value, options);
139
+ }
140
+ };
141
+ __decorate([
142
+ Configurable(DTO.String().optional()),
143
+ __metadata("design:type", String)
144
+ ], Translator.prototype, "locale", void 0);
145
+ __decorate([
146
+ Configurable(DTO.Any().optional()),
147
+ __metadata("design:type", Function)
148
+ ], Translator.prototype, "i18n", void 0);
149
+ Translator = __decorate([
150
+ Scoped(),
151
+ Autoload()
152
+ ], Translator);
153
+ export { Translator };
@@ -0,0 +1,18 @@
1
+ import { Exception } from '@lakutata/core';
2
+ /**
3
+ * Thrown when the messages or the configuration of the {@link I18n} component are invalid: a missing directory, a file
4
+ * which is not valid JSON, a file or directory name which is not a language tag, a key part containing `.`, a value
5
+ * which is neither a string nor an object, an option (`defaultLocale`, `locales`, `fallback`, `messages`) which is not
6
+ * a language tag, or an alias of `directory` without an application to resolve it. The component throws it when it is
7
+ * created, {@link I18n.addMessages} and {@link loadCatalogs} when they are called, and {@link generateMessageTypes} when
8
+ * the default language has no messages. Its `errno` is `'E_I18N_CATALOG'`.
9
+ */
10
+ export class I18nCatalogException extends Exception {
11
+ constructor() {
12
+ super(...arguments);
13
+ /**
14
+ * `'E_I18N_CATALOG'`.
15
+ */
16
+ this.errno = 'E_I18N_CATALOG';
17
+ }
18
+ }
@@ -0,0 +1,17 @@
1
+ import { Exception } from '@lakutata/core';
2
+ /**
3
+ * Thrown when a message is not valid ICU MessageFormat: an unclosed brace, an unknown argument type or style, a
4
+ * `plural`, `selectordinal` or `select` without its `other` option, a repeated selector. Its message names the
5
+ * language, the key and the position (`zh-CN: order.count: the "other" option is required at 1`). A catalog message is
6
+ * parsed the first time it is used, so {@link I18n.t} throws it then; {@link I18n.format} and {@link parseMessage}
7
+ * throw it at once. Its `errno` is `'E_I18N_MESSAGE_SYNTAX'`.
8
+ */
9
+ export class I18nMessageSyntaxException extends Exception {
10
+ constructor() {
11
+ super(...arguments);
12
+ /**
13
+ * `'E_I18N_MESSAGE_SYNTAX'`.
14
+ */
15
+ this.errno = 'E_I18N_MESSAGE_SYNTAX';
16
+ }
17
+ }
@@ -0,0 +1,15 @@
1
+ import { Exception } from '@lakutata/core';
2
+ /**
3
+ * Thrown by {@link I18n.t} (and the translators) when no message has the key, neither in the language asked nor in the
4
+ * languages it falls back to, and the `missing` option is `'throw'` (the default outside `NODE_ENV=production`). Its
5
+ * message lists the languages looked up. Its `errno` is `'E_I18N_MISSING_KEY'`.
6
+ */
7
+ export class I18nMissingKeyException extends Exception {
8
+ constructor() {
9
+ super(...arguments);
10
+ /**
11
+ * `'E_I18N_MISSING_KEY'`.
12
+ */
13
+ this.errno = 'E_I18N_MISSING_KEY';
14
+ }
15
+ }