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