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