@lakutata/i18n 3.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +347 -0
  3. package/dist/cjs/exports/I18n.d.ts +15 -0
  4. package/dist/cjs/exports/I18n.js +45 -0
  5. package/dist/cjs/exports/I18nMiddleware.d.ts +2 -0
  6. package/dist/cjs/exports/I18nMiddleware.js +21 -0
  7. package/dist/cjs/i18n/I18n.d.ts +313 -0
  8. package/dist/cjs/i18n/I18n.js +462 -0
  9. package/dist/cjs/i18n/Translator.d.ts +124 -0
  10. package/dist/cjs/i18n/Translator.js +156 -0
  11. package/dist/cjs/i18n/exceptions/I18nCatalogException.d.ts +15 -0
  12. package/dist/cjs/i18n/exceptions/I18nCatalogException.js +22 -0
  13. package/dist/cjs/i18n/exceptions/I18nMessageSyntaxException.d.ts +14 -0
  14. package/dist/cjs/i18n/exceptions/I18nMessageSyntaxException.js +21 -0
  15. package/dist/cjs/i18n/exceptions/I18nMissingKeyException.d.ts +12 -0
  16. package/dist/cjs/i18n/exceptions/I18nMissingKeyException.js +19 -0
  17. package/dist/cjs/i18n/exceptions/I18nMissingParameterException.d.ts +13 -0
  18. package/dist/cjs/i18n/exceptions/I18nMissingParameterException.js +20 -0
  19. package/dist/cjs/i18n/exceptions/I18nTranslatorException.d.ts +12 -0
  20. package/dist/cjs/i18n/exceptions/I18nTranslatorException.js +19 -0
  21. package/dist/cjs/i18n/interfaces/I18nOptions.d.ts +70 -0
  22. package/dist/cjs/i18n/interfaces/I18nOptions.js +2 -0
  23. package/dist/cjs/i18n/interfaces/ITranslator.d.ts +57 -0
  24. package/dist/cjs/i18n/interfaces/ITranslator.js +2 -0
  25. package/dist/cjs/i18n/lib/Catalog.d.ts +34 -0
  26. package/dist/cjs/i18n/lib/Catalog.js +104 -0
  27. package/dist/cjs/i18n/lib/CatalogTools.d.ts +86 -0
  28. package/dist/cjs/i18n/lib/CatalogTools.js +218 -0
  29. package/dist/cjs/i18n/lib/Locale.d.ts +95 -0
  30. package/dist/cjs/i18n/lib/Locale.js +182 -0
  31. package/dist/cjs/i18n/lib/LocaleTranslator.d.ts +93 -0
  32. package/dist/cjs/i18n/lib/LocaleTranslator.js +105 -0
  33. package/dist/cjs/i18n/lib/MessageFormat.d.ts +144 -0
  34. package/dist/cjs/i18n/lib/MessageFormat.js +372 -0
  35. package/dist/cjs/i18n/middleware/CommanderTranslator.d.ts +71 -0
  36. package/dist/cjs/i18n/middleware/CommanderTranslator.js +69 -0
  37. package/dist/cjs/i18n/middleware/I18nMiddleware.d.ts +126 -0
  38. package/dist/cjs/i18n/middleware/I18nMiddleware.js +210 -0
  39. package/dist/cjs/i18n/types/I18nMessages.d.ts +24 -0
  40. package/dist/cjs/i18n/types/I18nMessages.js +2 -0
  41. package/dist/cjs/package.json +1 -0
  42. package/dist/esm/exports/I18n.js +14 -0
  43. package/dist/esm/exports/I18nMiddleware.js +2 -0
  44. package/dist/esm/i18n/I18n.js +454 -0
  45. package/dist/esm/i18n/Translator.js +153 -0
  46. package/dist/esm/i18n/exceptions/I18nCatalogException.js +18 -0
  47. package/dist/esm/i18n/exceptions/I18nMessageSyntaxException.js +17 -0
  48. package/dist/esm/i18n/exceptions/I18nMissingKeyException.js +15 -0
  49. package/dist/esm/i18n/exceptions/I18nMissingParameterException.js +16 -0
  50. package/dist/esm/i18n/exceptions/I18nTranslatorException.js +15 -0
  51. package/dist/esm/i18n/interfaces/I18nOptions.js +1 -0
  52. package/dist/esm/i18n/interfaces/ITranslator.js +1 -0
  53. package/dist/esm/i18n/lib/Catalog.js +96 -0
  54. package/dist/esm/i18n/lib/CatalogTools.js +213 -0
  55. package/dist/esm/i18n/lib/Locale.js +173 -0
  56. package/dist/esm/i18n/lib/LocaleTranslator.js +101 -0
  57. package/dist/esm/i18n/lib/MessageFormat.js +367 -0
  58. package/dist/esm/i18n/middleware/CommanderTranslator.js +66 -0
  59. package/dist/esm/i18n/middleware/I18nMiddleware.js +206 -0
  60. package/dist/esm/i18n/types/I18nMessages.js +1 -0
  61. package/dist/types/exports/I18n.d.ts +15 -0
  62. package/dist/types/exports/I18nMiddleware.d.ts +2 -0
  63. package/dist/types/i18n/I18n.d.ts +313 -0
  64. package/dist/types/i18n/Translator.d.ts +124 -0
  65. package/dist/types/i18n/exceptions/I18nCatalogException.d.ts +15 -0
  66. package/dist/types/i18n/exceptions/I18nMessageSyntaxException.d.ts +14 -0
  67. package/dist/types/i18n/exceptions/I18nMissingKeyException.d.ts +12 -0
  68. package/dist/types/i18n/exceptions/I18nMissingParameterException.d.ts +13 -0
  69. package/dist/types/i18n/exceptions/I18nTranslatorException.d.ts +12 -0
  70. package/dist/types/i18n/interfaces/I18nOptions.d.ts +70 -0
  71. package/dist/types/i18n/interfaces/ITranslator.d.ts +57 -0
  72. package/dist/types/i18n/lib/Catalog.d.ts +34 -0
  73. package/dist/types/i18n/lib/CatalogTools.d.ts +86 -0
  74. package/dist/types/i18n/lib/Locale.d.ts +95 -0
  75. package/dist/types/i18n/lib/LocaleTranslator.d.ts +93 -0
  76. package/dist/types/i18n/lib/MessageFormat.d.ts +144 -0
  77. package/dist/types/i18n/middleware/CommanderTranslator.d.ts +71 -0
  78. package/dist/types/i18n/middleware/I18nMiddleware.d.ts +126 -0
  79. package/dist/types/i18n/types/I18nMessages.d.ts +24 -0
  80. package/package.json +60 -0
@@ -0,0 +1,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
+ }