@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,16 @@
|
|
|
1
|
+
import { Exception } from '@lakutata/core';
|
|
2
|
+
/**
|
|
3
|
+
* Thrown when a message is formatted without one of its arguments and the `missing` option is `'throw'` (the default
|
|
4
|
+
* outside `NODE_ENV=production`), or a `{price, number, currency}` argument has no currency (neither in its style, nor
|
|
5
|
+
* in the `currency` argument, nor in the component's `currency` option) and the `missing` option is `'throw'`. Its
|
|
6
|
+
* `errno` is `'E_I18N_MISSING_PARAMETER'`.
|
|
7
|
+
*/
|
|
8
|
+
export class I18nMissingParameterException extends Exception {
|
|
9
|
+
constructor() {
|
|
10
|
+
super(...arguments);
|
|
11
|
+
/**
|
|
12
|
+
* `'E_I18N_MISSING_PARAMETER'`.
|
|
13
|
+
*/
|
|
14
|
+
this.errno = 'E_I18N_MISSING_PARAMETER';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Exception } from '@lakutata/core';
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by the methods of a {@link Translator} which was not given its language by the {@link I18nMiddleware}: the
|
|
4
|
+
* entrypoint has no `I18nMiddleware`, or the `Translator` is used out of a request. Its `errno` is
|
|
5
|
+
* `'E_I18N_TRANSLATOR'`.
|
|
6
|
+
*/
|
|
7
|
+
export class I18nTranslatorException extends Exception {
|
|
8
|
+
constructor() {
|
|
9
|
+
super(...arguments);
|
|
10
|
+
/**
|
|
11
|
+
* `'E_I18N_TRANSLATOR'`.
|
|
12
|
+
*/
|
|
13
|
+
this.errno = 'E_I18N_TRANSLATOR';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { I18nCatalogException } from '../exceptions/I18nCatalogException.js';
|
|
5
|
+
import { normalizeLocale } from './Locale.js';
|
|
6
|
+
/**
|
|
7
|
+
* The messages of nested objects by their keys: {"order": {"count": "..."}} → order.count
|
|
8
|
+
* @param messages
|
|
9
|
+
* @param source where the messages are, for the errors
|
|
10
|
+
* @param prefix
|
|
11
|
+
*/
|
|
12
|
+
export function flattenMessages(messages, source, prefix = '') {
|
|
13
|
+
const flat = new Map();
|
|
14
|
+
if (messages === null || typeof messages !== 'object' || Array.isArray(messages))
|
|
15
|
+
throw new I18nCatalogException(`${source}: the messages must be an object${prefix ? ` (${prefix.slice(0, -1)})` : ''}`);
|
|
16
|
+
for (const [key, value] of Object.entries(messages)) {
|
|
17
|
+
if (!key || key.includes('.'))
|
|
18
|
+
throw new I18nCatalogException(`${source}: invalid key "${prefix}${key}" (the parts of the keys are nested objects)`);
|
|
19
|
+
if (typeof value === 'string')
|
|
20
|
+
flat.set(`${prefix}${key}`, value);
|
|
21
|
+
else
|
|
22
|
+
flattenMessages(value, source, `${prefix}${key}.`).forEach((message, name) => flat.set(name, message));
|
|
23
|
+
}
|
|
24
|
+
return flat;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Add the messages of a language to catalogs, the ones already there replaced
|
|
28
|
+
* @param catalogs
|
|
29
|
+
* @param locale
|
|
30
|
+
* @param messages
|
|
31
|
+
*/
|
|
32
|
+
export function mergeMessages(catalogs, locale, messages) {
|
|
33
|
+
let catalog = catalogs.get(locale);
|
|
34
|
+
if (!catalog)
|
|
35
|
+
catalogs.set(locale, catalog = new Map());
|
|
36
|
+
messages.forEach((message, key) => catalog.set(key, message));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The language of a name of a file or a directory of a catalog (zh-CN.json, zh-CN)
|
|
40
|
+
* @param name
|
|
41
|
+
* @param file
|
|
42
|
+
*/
|
|
43
|
+
function localeOfName(name, file) {
|
|
44
|
+
const locale = normalizeLocale(name);
|
|
45
|
+
if (!locale || locale.toLowerCase() !== name.toLowerCase().replace(/_/g, '-'))
|
|
46
|
+
throw new I18nCatalogException(`${file}: "${name}" is not the tag of a language (en, zh-CN, zh-Hant-TW)`);
|
|
47
|
+
return locale;
|
|
48
|
+
}
|
|
49
|
+
async function readJson(file) {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
throw new I18nCatalogException(`${file}: ${e.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Load the messages of a directory into catalogs: a file by language (zh-CN.json), or a directory by language whose
|
|
59
|
+
* files (and their directories) prefix their keys (zh-CN/order.json: order.*, zh-CN/admin/user.json: admin.user.*)
|
|
60
|
+
* @param catalogs
|
|
61
|
+
* @param directory
|
|
62
|
+
*/
|
|
63
|
+
export async function loadDirectory(catalogs, directory) {
|
|
64
|
+
if (!existsSync(directory))
|
|
65
|
+
throw new I18nCatalogException(`${directory}: the directory of the messages does not exist`);
|
|
66
|
+
const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
const file = path.join(directory, entry.name);
|
|
69
|
+
if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
70
|
+
mergeMessages(catalogs, localeOfName(entry.name.slice(0, -5), file), flattenMessages(await readJson(file), file));
|
|
71
|
+
}
|
|
72
|
+
else if (entry.isDirectory()) {
|
|
73
|
+
const locale = localeOfName(entry.name, file);
|
|
74
|
+
await loadNamespaces(catalogs, locale, file, '');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Load the files of the directory of a language, their names prefixing their keys
|
|
80
|
+
* @param catalogs
|
|
81
|
+
* @param locale
|
|
82
|
+
* @param directory
|
|
83
|
+
* @param prefix
|
|
84
|
+
*/
|
|
85
|
+
async function loadNamespaces(catalogs, locale, directory, prefix) {
|
|
86
|
+
const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const file = path.join(directory, entry.name);
|
|
89
|
+
if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
90
|
+
mergeMessages(catalogs, locale, flattenMessages(await readJson(file), file, `${prefix}${entry.name.slice(0, -5)}.`));
|
|
91
|
+
}
|
|
92
|
+
else if (entry.isDirectory()) {
|
|
93
|
+
await loadNamespaces(catalogs, locale, file, `${prefix}${entry.name}.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { defaultValidationMessages } from '@lakutata/core';
|
|
2
|
+
import { loadDirectory } from './Catalog.js';
|
|
3
|
+
import { I18nCatalogException } from '../exceptions/I18nCatalogException.js';
|
|
4
|
+
import { normalizeLocale } from './Locale.js';
|
|
5
|
+
import { messageArguments, parseMessage } from './MessageFormat.js';
|
|
6
|
+
/**
|
|
7
|
+
* The prefix of the keys of the messages of the validation errors (their templates are the ones of the validation, not
|
|
8
|
+
* ICU MessageFormat)
|
|
9
|
+
*/
|
|
10
|
+
const VALIDATION_PREFIX = 'validation.';
|
|
11
|
+
/**
|
|
12
|
+
* Load the messages of directories as the {@link I18n} component does, to check them or generate their types out of
|
|
13
|
+
* an application (in a script, a test or the CI).
|
|
14
|
+
* @param directories The directories, each holding a JSON file (`zh-CN.json`) or a directory (`zh-CN/order.json`) per
|
|
15
|
+
* language; paths resolved from the working directory (the aliases are not resolved). The later directories replace
|
|
16
|
+
* the same keys of the earlier ones.
|
|
17
|
+
* @returns The messages, by language then by key.
|
|
18
|
+
* @throws {I18nCatalogException} When a directory is missing, a file is not valid JSON, a name is not a language tag
|
|
19
|
+
* or a key is invalid.
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* import {loadCatalogs, type Catalogs} from 'lakutata/com/i18n'
|
|
23
|
+
*
|
|
24
|
+
* const catalogs: Catalogs = await loadCatalogs(['./locales'])
|
|
25
|
+
* for (const [locale, messages] of catalogs) console.log(locale, messages.size)
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export async function loadCatalogs(directories) {
|
|
29
|
+
const catalogs = new Map();
|
|
30
|
+
for (const directory of directories)
|
|
31
|
+
await loadDirectory(catalogs, directory);
|
|
32
|
+
return catalogs;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The variables of a template of the validation (#label, #limit)
|
|
36
|
+
* @param template
|
|
37
|
+
*/
|
|
38
|
+
function templateVariables(template) {
|
|
39
|
+
return new Set(template.match(/#[A-Za-z]+/g) ?? []);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Parse a message, its syntax error an issue
|
|
43
|
+
* @param issues
|
|
44
|
+
* @param locale
|
|
45
|
+
* @param key
|
|
46
|
+
* @param message
|
|
47
|
+
*/
|
|
48
|
+
function parse(issues, locale, key, message) {
|
|
49
|
+
try {
|
|
50
|
+
return messageArguments(parseMessage(message, `${locale}: ${key}`));
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
issues.push({ level: 'error', locale: locale, key: key, message: error.message });
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Check every language's messages against the default language's messages, as `lakutata i18n check` does. It reports:
|
|
59
|
+
* - errors: an ICU syntax error (in any language), a key the default language does not have, an argument the default
|
|
60
|
+
* message does not have (the code does not give it), a `validation.<code>` message whose code is unknown or which
|
|
61
|
+
* uses a variable that code does not have (`{{#limit}}`);
|
|
62
|
+
* - warnings: a key of the default language missing in a language (its fallback is given), an argument of the default
|
|
63
|
+
* message not used, a message identical to the default one and containing letters (not translated yet).
|
|
64
|
+
*
|
|
65
|
+
* The `validation.*` messages are not compared between languages; they need not exist in the default language.
|
|
66
|
+
* @param catalogs The messages, as {@link loadCatalogs} gives them.
|
|
67
|
+
* @param defaultLocale The default language tag, normalized (`zh_CN` is `zh-CN`).
|
|
68
|
+
* @returns The issues, by language in alphabetical order; empty when everything is right. Only one error when the
|
|
69
|
+
* default language has no messages.
|
|
70
|
+
* @example
|
|
71
|
+
* ```typescript
|
|
72
|
+
* import {checkCatalogs, loadCatalogs, type CatalogIssue} from 'lakutata/com/i18n'
|
|
73
|
+
*
|
|
74
|
+
* const issues: CatalogIssue[] = checkCatalogs(await loadCatalogs(['./locales']), 'en')
|
|
75
|
+
* for (const issue of issues) console.log(`${issue.level} ${issue.locale} ${issue.key}: ${issue.message}`)
|
|
76
|
+
* if (issues.some((issue: CatalogIssue): boolean => issue.level === 'error')) process.exitCode = 1
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function checkCatalogs(catalogs, defaultLocale) {
|
|
80
|
+
const issues = [];
|
|
81
|
+
const base = normalizeLocale(defaultLocale) ?? defaultLocale;
|
|
82
|
+
const defaults = catalogs.get(base);
|
|
83
|
+
if (!defaults)
|
|
84
|
+
return [{ level: 'error', locale: base, key: '', message: `There are no messages of the default language ${base} (${[...catalogs.keys()].join(', ') || 'no language'})` }];
|
|
85
|
+
const validation = defaultValidationMessages();
|
|
86
|
+
//The arguments of the default messages
|
|
87
|
+
const baseArguments = new Map();
|
|
88
|
+
for (const [key, message] of defaults) {
|
|
89
|
+
if (!key.startsWith(VALIDATION_PREFIX))
|
|
90
|
+
baseArguments.set(key, parse(issues, base, key, message));
|
|
91
|
+
}
|
|
92
|
+
for (const [locale, messages] of [...catalogs.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
93
|
+
for (const [key, message] of messages) {
|
|
94
|
+
if (key.startsWith(VALIDATION_PREFIX)) {
|
|
95
|
+
const code = key.slice(VALIDATION_PREFIX.length);
|
|
96
|
+
if (!(code in validation)) {
|
|
97
|
+
issues.push({ level: 'error', locale: locale, key: key, message: `"${code}" is not a code of the validation errors` });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const known = templateVariables(validation[code]);
|
|
101
|
+
const unknown = [...templateVariables(message)].filter((variable) => !known.has(variable));
|
|
102
|
+
if (unknown.length)
|
|
103
|
+
issues.push({ level: 'error', locale: locale, key: key, message: `${unknown.join(', ')} ${unknown.length > 1 ? 'are not variables' : 'is not a variable'} of "${code}" (${[...known].join(', ')})` });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (locale === base)
|
|
107
|
+
continue;
|
|
108
|
+
if (!defaults.has(key)) {
|
|
109
|
+
issues.push({ level: 'error', locale: locale, key: key, message: `The default language ${base} has no message "${key}"` });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const args = parse(issues, locale, key, message);
|
|
113
|
+
const expected = baseArguments.get(key);
|
|
114
|
+
if (!args || !expected)
|
|
115
|
+
continue;
|
|
116
|
+
const added = [...args.keys()].filter((name) => !expected.has(name));
|
|
117
|
+
if (added.length)
|
|
118
|
+
issues.push({ level: 'error', locale: locale, key: key, message: `The argument${added.length > 1 ? 's' : ''} ${added.join(', ')} of the message ${added.length > 1 ? 'are' : 'is'} not given to the message of ${base}` });
|
|
119
|
+
const unused = [...expected.keys()].filter((name) => !args.has(name));
|
|
120
|
+
if (unused.length)
|
|
121
|
+
issues.push({ level: 'warning', locale: locale, key: key, message: `The argument${unused.length > 1 ? 's' : ''} ${unused.join(', ')} of the message of ${base} ${unused.length > 1 ? 'are' : 'is'} not used` });
|
|
122
|
+
if (message === defaults.get(key) && /\p{L}/u.test(message))
|
|
123
|
+
issues.push({ level: 'warning', locale: locale, key: key, message: `The message is the one of ${base} (not translated yet?)` });
|
|
124
|
+
}
|
|
125
|
+
if (locale === base)
|
|
126
|
+
continue;
|
|
127
|
+
for (const key of defaults.keys()) {
|
|
128
|
+
if (!key.startsWith(VALIDATION_PREFIX) && !messages.has(key))
|
|
129
|
+
issues.push({ level: 'warning', locale: locale, key: key, message: 'The message is missing (the one of a fallback is given)' });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return issues;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The TypeScript type of an argument of a message, by its use
|
|
136
|
+
* @param type
|
|
137
|
+
*/
|
|
138
|
+
function argumentType(type) {
|
|
139
|
+
switch (type) {
|
|
140
|
+
case 'number':
|
|
141
|
+
return 'number | bigint';
|
|
142
|
+
case 'date':
|
|
143
|
+
case 'time':
|
|
144
|
+
return 'Date | number | string';
|
|
145
|
+
case 'plural':
|
|
146
|
+
case 'selectordinal':
|
|
147
|
+
return 'number';
|
|
148
|
+
case 'select':
|
|
149
|
+
return 'string';
|
|
150
|
+
default:
|
|
151
|
+
return 'string | number | bigint | boolean | Date';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Whether a message has a currency argument without its currency ({price, number, currency}: the currency argument may
|
|
156
|
+
* give it)
|
|
157
|
+
* @param parts
|
|
158
|
+
*/
|
|
159
|
+
function hasCurrency(parts) {
|
|
160
|
+
return parts.some((part) => typeof part !== 'string' && part.type !== 'pound' &&
|
|
161
|
+
((part.type === 'number' && part.style === 'currency') || ('options' in part && Object.values(part.options).some(hasCurrency))));
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* A string literal of TypeScript
|
|
165
|
+
* @param value
|
|
166
|
+
*/
|
|
167
|
+
function literal(value) {
|
|
168
|
+
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Generate the TypeScript declaration of the default language's message keys and their arguments (the
|
|
172
|
+
* {@link I18nMessages} augmentation), as `lakutata i18n types` does. Each argument is typed by its use: `number |
|
|
173
|
+
* bigint` for `{n, number}`, `number` for a plural, `string` for a select, `Date | number | string` for a date or a
|
|
174
|
+
* time, any scalar for a plain argument; a currency argument without its currency adds an optional `currency`. The
|
|
175
|
+
* `validation.*` messages are left out.
|
|
176
|
+
* @param catalogs The messages, as {@link loadCatalogs} gives them.
|
|
177
|
+
* @param defaultLocale The default language tag, whose messages are declared.
|
|
178
|
+
* @param moduleName The module the application imports the i18n from, which the declaration augments:
|
|
179
|
+
* `'lakutata/com/i18n'` by default, `'@lakutata/i18n'` for the applications importing the package directly.
|
|
180
|
+
* @returns The source of a `.d.ts` file.
|
|
181
|
+
* @throws {I18nCatalogException} When the default language has no messages.
|
|
182
|
+
* @throws {I18nMessageSyntaxException} When a message of the default language is not valid ICU MessageFormat.
|
|
183
|
+
* @example
|
|
184
|
+
* ```typescript
|
|
185
|
+
* import {writeFile} from 'node:fs/promises'
|
|
186
|
+
* import {generateMessageTypes, loadCatalogs} from 'lakutata/com/i18n'
|
|
187
|
+
*
|
|
188
|
+
* await writeFile('src/i18n.d.ts', generateMessageTypes(await loadCatalogs(['./locales']), 'en'))
|
|
189
|
+
* ```
|
|
190
|
+
*/
|
|
191
|
+
export function generateMessageTypes(catalogs, defaultLocale, moduleName = 'lakutata/com/i18n') {
|
|
192
|
+
const base = normalizeLocale(defaultLocale) ?? defaultLocale;
|
|
193
|
+
const defaults = catalogs.get(base);
|
|
194
|
+
if (!defaults)
|
|
195
|
+
throw new I18nCatalogException(`There are no messages of the default language ${base}`);
|
|
196
|
+
const lines = [];
|
|
197
|
+
for (const key of [...defaults.keys()].filter((name) => !name.startsWith(VALIDATION_PREFIX)).sort()) {
|
|
198
|
+
const parts = parseMessage(defaults.get(key), `${base}: ${key}`);
|
|
199
|
+
const fields = [...messageArguments(parts)].map(([name, type]) => `${/^[A-Za-z_$][\w$]*$/.test(name) ? name : literal(name)}: ${argumentType(type)}`);
|
|
200
|
+
if (hasCurrency(parts) && !messageArguments(parts).has('currency'))
|
|
201
|
+
fields.push('currency?: string');
|
|
202
|
+
lines.push(` ${literal(key)}: ${fields.length ? `{ ${fields.join(', ')} }` : 'Record<string, never>'}`);
|
|
203
|
+
}
|
|
204
|
+
return `//Generated by lakutata i18n types from the messages of ${base}: the keys of the messages and their arguments
|
|
205
|
+
import ${literal(moduleName)}
|
|
206
|
+
|
|
207
|
+
declare module ${literal(moduleName)} {
|
|
208
|
+
interface I18nMessages {
|
|
209
|
+
${lines.join('\n')}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
`;
|
|
213
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Give the canonical BCP 47 form of a language tag (`zh-cn` gives `zh-CN`, `iw` gives `he`). The POSIX locales of the
|
|
3
|
+
* environment are converted: their encoding and modifier dropped, `_` replaced by `-` (`zh_CN.UTF-8` gives `zh-CN`,
|
|
4
|
+
* `en_US@euro` gives `en-US`).
|
|
5
|
+
* @param tag The tag; surrounding spaces are ignored.
|
|
6
|
+
* @returns The canonical tag; `undefined` for an invalid tag, a tag whose language is not 2 or 3 letters, `C`,
|
|
7
|
+
* `POSIX`, `*` and the empty string.
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import {normalizeLocale} from 'lakutata/com/i18n'
|
|
11
|
+
*
|
|
12
|
+
* console.log(normalizeLocale('zh_CN.UTF-8')) //zh-CN
|
|
13
|
+
* console.log(normalizeLocale('zh-hant-tw')) //zh-Hant-TW
|
|
14
|
+
* console.log(normalizeLocale('C')) //undefined
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeLocale(tag) {
|
|
18
|
+
//The encoding and the modifier of the locales of the environment: zh_CN.UTF-8, de_DE@euro
|
|
19
|
+
const trimmed = tag.trim().replace(/[.@].*$/, '').replace(/_/g, '-');
|
|
20
|
+
if (!trimmed || trimmed === 'C' || trimmed === 'POSIX' || trimmed === '*')
|
|
21
|
+
return undefined;
|
|
22
|
+
//The languages are their codes of ISO 639 (2 or 3 letters): the longer subtags of BCP 47 are reserved
|
|
23
|
+
if (!/^[a-z]{2,3}(-|$)/i.test(trimmed))
|
|
24
|
+
return undefined;
|
|
25
|
+
try {
|
|
26
|
+
return Intl.getCanonicalLocales(trimmed)[0];
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The language of a tag, without its script and region: zh-Hant-TW → zh
|
|
34
|
+
* @param locale
|
|
35
|
+
*/
|
|
36
|
+
export function languageOf(locale) {
|
|
37
|
+
return locale.split('-')[0].toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Read the languages of an `Accept-Language` header, preferred first: by decreasing quality (`q`, 1 when absent),
|
|
41
|
+
* then in their order in the header. The tags are normalized ({@link normalizeLocale}); the invalid ones, `*`, and the
|
|
42
|
+
* ones of quality 0 or an invalid quality are dropped.
|
|
43
|
+
* @param header The value of the header (`zh-CN,zh;q=0.9,en;q=0.8`).
|
|
44
|
+
* @returns The language tags, preferred first; empty without a header.
|
|
45
|
+
* @example
|
|
46
|
+
* ```typescript
|
|
47
|
+
* import {parseAcceptLanguage} from 'lakutata/com/i18n'
|
|
48
|
+
*
|
|
49
|
+
* console.log(parseAcceptLanguage('en;q=0.5,fr,*;q=0.1')) //['fr', 'en']
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function parseAcceptLanguage(header) {
|
|
53
|
+
if (!header)
|
|
54
|
+
return [];
|
|
55
|
+
return header.split(',')
|
|
56
|
+
.map((part, index) => {
|
|
57
|
+
const [tag, ...parameters] = part.split(';');
|
|
58
|
+
const quality = parameters.map((parameter) => parameter.trim()).find((parameter) => parameter.startsWith('q='));
|
|
59
|
+
return { locale: normalizeLocale(tag), quality: quality === undefined ? 1 : Number(quality.slice(2)), index: index };
|
|
60
|
+
})
|
|
61
|
+
.filter((item) => item.locale !== undefined && item.quality > 0 && !Number.isNaN(item.quality))
|
|
62
|
+
//By quality, then in their order
|
|
63
|
+
.sort((a, b) => b.quality - a.quality || a.index - b.index)
|
|
64
|
+
.map((item) => item.locale);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Read the languages of a process environment, preferred first, as gettext does: the locale is the first of `LC_ALL`,
|
|
68
|
+
* `LC_MESSAGES` and `LANG` which is set; the colon-separated list of `LANGUAGE` comes before it, but only when that
|
|
69
|
+
* locale is set and valid (not `C` or `POSIX`). The tags are normalized ({@link normalizeLocale}), the invalid ones
|
|
70
|
+
* and the duplicates dropped.
|
|
71
|
+
* @param environment The environment variables: `process.env` by default.
|
|
72
|
+
* @returns The language tags, preferred first; empty when the environment gives none.
|
|
73
|
+
* @example
|
|
74
|
+
* ```typescript
|
|
75
|
+
* import {environmentLocales} from 'lakutata/com/i18n'
|
|
76
|
+
*
|
|
77
|
+
* console.log(environmentLocales({LANG: 'zh_CN.UTF-8', LANGUAGE: 'zh_TW:en'})) //['zh-TW', 'en', 'zh-CN']
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export function environmentLocales(environment = process.env) {
|
|
81
|
+
//LANGUAGE is used by gettext only when the locale is not C
|
|
82
|
+
const locale = environment.LC_ALL || environment.LC_MESSAGES || environment.LANG;
|
|
83
|
+
const list = locale && normalizeLocale(locale) !== undefined && environment.LANGUAGE ? environment.LANGUAGE.split(':') : [];
|
|
84
|
+
return unique([...list, ...(locale ? [locale] : [])].map(normalizeLocale).filter((tag) => tag !== undefined));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The languages of the process, preferred first: the one of an environment variable of the application
|
|
88
|
+
* (LAKUTATA_LANG), the ones of the environment (environmentLocales), the language of the process (of its ICU)
|
|
89
|
+
* @param variable the environment variable of the application
|
|
90
|
+
* @param environment
|
|
91
|
+
*/
|
|
92
|
+
export function processLocales(variable, environment = process.env) {
|
|
93
|
+
const own = variable ? normalizeLocale(environment[variable] ?? '') : undefined;
|
|
94
|
+
return unique([...(own ? [own] : []), ...environmentLocales(environment), new Intl.DateTimeFormat().resolvedOptions().locale]);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Choose the supported language for the requested ones. Each requested language is tried in turn, preferred first,
|
|
98
|
+
* and the first match wins: the same tag (case-insensitive), then its language alone (`en-GB` matches `en`), then
|
|
99
|
+
* another tag of its language (`pt-PT` matches `pt-BR`, the first one in the order of `supported`). The invalid
|
|
100
|
+
* requested tags are skipped.
|
|
101
|
+
* @param requested The requested language tags, preferred first (from {@link parseAcceptLanguage} or
|
|
102
|
+
* {@link environmentLocales}).
|
|
103
|
+
* @param supported The supported language tags.
|
|
104
|
+
* @param defaultLocale The language returned when none of the requested ones matches.
|
|
105
|
+
* @returns The matching tag as written in `supported`, or `defaultLocale`.
|
|
106
|
+
* @example
|
|
107
|
+
* ```typescript
|
|
108
|
+
* import {negotiateLocale} from 'lakutata/com/i18n'
|
|
109
|
+
*
|
|
110
|
+
* console.log(negotiateLocale(['pt-PT', 'en'], ['en', 'pt-BR'], 'en')) //pt-BR
|
|
111
|
+
* console.log(negotiateLocale(['de'], ['en', 'zh-CN'], 'en')) //en
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
export function negotiateLocale(requested, supported, defaultLocale) {
|
|
115
|
+
const canonical = supported.map((tag) => normalizeLocale(tag) ?? tag);
|
|
116
|
+
const find = (predicate) => {
|
|
117
|
+
const index = canonical.findIndex(predicate);
|
|
118
|
+
return index < 0 ? undefined : supported[index];
|
|
119
|
+
};
|
|
120
|
+
for (const tag of requested) {
|
|
121
|
+
const locale = normalizeLocale(tag);
|
|
122
|
+
if (!locale)
|
|
123
|
+
continue;
|
|
124
|
+
const lower = locale.toLowerCase();
|
|
125
|
+
const language = languageOf(locale);
|
|
126
|
+
const matched = find((candidate) => candidate.toLowerCase() === lower) ??
|
|
127
|
+
find((candidate) => candidate.toLowerCase() === language) ??
|
|
128
|
+
find((candidate) => languageOf(candidate) === language);
|
|
129
|
+
if (matched)
|
|
130
|
+
return matched;
|
|
131
|
+
}
|
|
132
|
+
return defaultLocale;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* List the languages a message of a language is looked up in, in their order, each once: the language, then each of
|
|
136
|
+
* its configured fallbacks (followed by their own fallbacks and less specific tags), then its less specific tags
|
|
137
|
+
* (`zh-Hant-TW`, `zh-Hant`, `zh`), then the default language (and its own fallbacks and less specific tags). The tags
|
|
138
|
+
* are not normalized: give canonical ones.
|
|
139
|
+
* @param locale The language tag of the message asked.
|
|
140
|
+
* @param fallbacks The configured fallbacks, by language tag.
|
|
141
|
+
* @param defaultLocale The default language tag.
|
|
142
|
+
* @returns The language tags, in lookup order.
|
|
143
|
+
* @example
|
|
144
|
+
* ```typescript
|
|
145
|
+
* import {fallbackChain} from 'lakutata/com/i18n'
|
|
146
|
+
*
|
|
147
|
+
* console.log(fallbackChain('zh-TW', {'zh-TW': ['zh-CN']}, 'en')) //['zh-TW', 'zh-CN', 'zh', 'en']
|
|
148
|
+
* console.log(fallbackChain('zh-Hant-TW', {}, 'en')) //['zh-Hant-TW', 'zh-Hant', 'zh', 'en']
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
export function fallbackChain(locale, fallbacks, defaultLocale) {
|
|
152
|
+
const chain = [];
|
|
153
|
+
const visit = (tag) => {
|
|
154
|
+
if (chain.includes(tag))
|
|
155
|
+
return;
|
|
156
|
+
chain.push(tag);
|
|
157
|
+
for (const fallback of fallbacks[tag] ?? [])
|
|
158
|
+
visit(fallback);
|
|
159
|
+
const parts = tag.split('-');
|
|
160
|
+
if (parts.length > 1)
|
|
161
|
+
visit(parts.slice(0, -1).join('-'));
|
|
162
|
+
};
|
|
163
|
+
visit(locale);
|
|
164
|
+
visit(defaultLocale);
|
|
165
|
+
return chain;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The values once, in their order
|
|
169
|
+
* @param values
|
|
170
|
+
*/
|
|
171
|
+
function unique(values) {
|
|
172
|
+
return [...new Set(values)];
|
|
173
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The messages of one language, without giving the language at each call: {@link I18n.translator} creates one, to use
|
|
3
|
+
* out of a request (a schedule, a singleton component, a background job). Its methods call the component's methods
|
|
4
|
+
* with its language, so the messages added later with {@link I18n.addMessages} are seen too. In a request, inject the
|
|
5
|
+
* {@link Translator} instead.
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import {Application} from 'lakutata'
|
|
9
|
+
* import {buildI18nOptions, type I18n, type ITranslator} from 'lakutata/com/i18n'
|
|
10
|
+
*
|
|
11
|
+
* Application
|
|
12
|
+
* .run(() => ({
|
|
13
|
+
* id: 'i18n.app',
|
|
14
|
+
* name: 'I18n',
|
|
15
|
+
* components: {
|
|
16
|
+
* i18n: buildI18nOptions({
|
|
17
|
+
* messages: {
|
|
18
|
+
* 'en': {order: {count: '{count, plural, =0 {No orders} one {# order} other {# orders}}'}},
|
|
19
|
+
* 'de': {order: {count: '{count, plural, =0 {Keine Bestellungen} one {# Bestellung} other {# Bestellungen}}'}}
|
|
20
|
+
* }
|
|
21
|
+
* })
|
|
22
|
+
* }
|
|
23
|
+
* }))
|
|
24
|
+
* .onLaunched(async (app: Application): Promise<void> => {
|
|
25
|
+
* const i18n: I18n = await app.getObject('i18n')
|
|
26
|
+
* const german: ITranslator = i18n.translator('de-DE')
|
|
27
|
+
* console.log(german.locale) //de-DE, its messages looked up in de-DE, then de, then en
|
|
28
|
+
* console.log(german.t('order.count', {count: 1000})) //1.000 Bestellungen
|
|
29
|
+
* console.log(german.number(1234.5)) //1.234,5
|
|
30
|
+
* })
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export class LocaleTranslator {
|
|
34
|
+
/**
|
|
35
|
+
* Create the translator of a language; {@link I18n.translator} does it, normalizing the language first.
|
|
36
|
+
* @param i18n The component holding the messages.
|
|
37
|
+
* @param locale The language tag, canonical.
|
|
38
|
+
*/
|
|
39
|
+
constructor(i18n, locale) {
|
|
40
|
+
this.i18n = i18n;
|
|
41
|
+
this.locale = locale;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Give the message of a key in the translator's language, formatted with its arguments (see {@link I18n.t}).
|
|
45
|
+
* @param key The key of the message (`order.count`).
|
|
46
|
+
* @param params The arguments of the message, by their names.
|
|
47
|
+
* @returns The formatted message. With `missing: 'key'`, the key itself when it is missing, `{name}` in place of a
|
|
48
|
+
* missing argument, a plain number for a currency argument without a currency (each with a warning, once).
|
|
49
|
+
* @throws {I18nMissingKeyException} When no message has the key and the `missing` option is `'throw'`.
|
|
50
|
+
* @throws {I18nMissingParameterException} When an argument is missing, or a currency argument has no currency, and
|
|
51
|
+
* the `missing` option is `'throw'`.
|
|
52
|
+
* @throws {I18nMessageSyntaxException} When the message is not valid ICU MessageFormat.
|
|
53
|
+
*/
|
|
54
|
+
t(key, params) {
|
|
55
|
+
return this.i18n.t(key, params, this.locale);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Tell whether a key has a message in the translator's language or in the languages it falls back to.
|
|
59
|
+
* @param key The key of the message.
|
|
60
|
+
* @returns `true` when {@link LocaleTranslator.t} finds a message for the key.
|
|
61
|
+
*/
|
|
62
|
+
has(key) {
|
|
63
|
+
return this.i18n.has(key, this.locale);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Format an ICU MessageFormat message which is not in the catalogs, in the translator's language.
|
|
67
|
+
* @param message The message (`Hello, {name}`).
|
|
68
|
+
* @param params The arguments of the message, by their names.
|
|
69
|
+
* @returns The formatted message. With `missing: 'key'`, `{name}` in place of a missing argument, a plain number
|
|
70
|
+
* for a currency argument without a currency (each with a warning, once).
|
|
71
|
+
* @throws {I18nMessageSyntaxException} When the message is not valid ICU MessageFormat.
|
|
72
|
+
* @throws {I18nMissingParameterException} When an argument is missing, or a currency argument has no currency, and
|
|
73
|
+
* the `missing` option is `'throw'`.
|
|
74
|
+
*/
|
|
75
|
+
format(message, params) {
|
|
76
|
+
return this.i18n.format(message, params, this.locale);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Format a number in the translator's language (`1234.5` gives `1,234.5` in English, `1.234,5` in German).
|
|
80
|
+
* @param value The number.
|
|
81
|
+
* @param options The options of `Intl.NumberFormat` (`{style: 'currency', currency: 'EUR'}`); the component's
|
|
82
|
+
* `currency` option is not applied here.
|
|
83
|
+
* @returns The formatted number.
|
|
84
|
+
* @throws {RangeError} When the options are invalid (a currency style without its currency).
|
|
85
|
+
*/
|
|
86
|
+
number(value, options) {
|
|
87
|
+
return new Intl.NumberFormat(this.locale, options).format(value);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Format a date (and its time when the options ask for it) in the translator's language, in the component's
|
|
91
|
+
* `timeZone` (the process's one without it).
|
|
92
|
+
* @param value The date, or a timestamp in milliseconds.
|
|
93
|
+
* @param options The options of `Intl.DateTimeFormat` (`{dateStyle: 'long'}`); their `timeZone` replaces the
|
|
94
|
+
* component's one.
|
|
95
|
+
* @returns The formatted date.
|
|
96
|
+
* @throws {RangeError} When the date is invalid or the options are.
|
|
97
|
+
*/
|
|
98
|
+
date(value, options) {
|
|
99
|
+
return new Intl.DateTimeFormat(this.locale, { timeZone: this.i18n.getTimeZone(), ...options }).format(value);
|
|
100
|
+
}
|
|
101
|
+
}
|