@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,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
|
+
}
|