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