@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,34 @@
1
+ /**
2
+ * The messages of a language, nested by the parts of their keys: `{order: {count: '...'}}` holds the key
3
+ * `order.count`. A key part cannot be empty or contain `.`, and every leaf is a string (ICU MessageFormat, or a
4
+ * validation template under `validation`).
5
+ */
6
+ export type NestedMessages = {
7
+ [key: string]: string | NestedMessages;
8
+ };
9
+ /**
10
+ * Loaded messages, by canonical language tag then by full key (`order.count`), as {@link loadCatalogs} gives them to
11
+ * {@link checkCatalogs} and {@link generateMessageTypes}.
12
+ */
13
+ export type Catalogs = Map<string, Map<string, string>>;
14
+ /**
15
+ * The messages of nested objects by their keys: {"order": {"count": "..."}} → order.count
16
+ * @param messages
17
+ * @param source where the messages are, for the errors
18
+ * @param prefix
19
+ */
20
+ export declare function flattenMessages(messages: unknown, source: string, prefix?: string): Map<string, string>;
21
+ /**
22
+ * Add the messages of a language to catalogs, the ones already there replaced
23
+ * @param catalogs
24
+ * @param locale
25
+ * @param messages
26
+ */
27
+ export declare function mergeMessages(catalogs: Catalogs, locale: string, messages: Map<string, string>): void;
28
+ /**
29
+ * Load the messages of a directory into catalogs: a file by language (zh-CN.json), or a directory by language whose
30
+ * files (and their directories) prefix their keys (zh-CN/order.json: order.*, zh-CN/admin/user.json: admin.user.*)
31
+ * @param catalogs
32
+ * @param directory
33
+ */
34
+ export declare function loadDirectory(catalogs: Catalogs, directory: string): Promise<void>;
@@ -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,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,93 @@
1
+ import type { I18n } from '../I18n.js';
2
+ import type { ITranslator } from '../interfaces/ITranslator.js';
3
+ import type { MessageKey, MessageParams } from '../types/I18nMessages.js';
4
+ /**
5
+ * The messages of one language, without giving the language at each call: {@link I18n.translator} creates one, to use
6
+ * out of a request (a schedule, a singleton component, a background job). Its methods call the component's methods
7
+ * with its language, so the messages added later with {@link I18n.addMessages} are seen too. In a request, inject the
8
+ * {@link Translator} instead.
9
+ * @example
10
+ * ```typescript
11
+ * import {Application} from 'lakutata'
12
+ * import {buildI18nOptions, type I18n, type ITranslator} from 'lakutata/com/i18n'
13
+ *
14
+ * Application
15
+ * .run(() => ({
16
+ * id: 'i18n.app',
17
+ * name: 'I18n',
18
+ * components: {
19
+ * i18n: buildI18nOptions({
20
+ * messages: {
21
+ * 'en': {order: {count: '{count, plural, =0 {No orders} one {# order} other {# orders}}'}},
22
+ * 'de': {order: {count: '{count, plural, =0 {Keine Bestellungen} one {# Bestellung} other {# Bestellungen}}'}}
23
+ * }
24
+ * })
25
+ * }
26
+ * }))
27
+ * .onLaunched(async (app: Application): Promise<void> => {
28
+ * const i18n: I18n = await app.getObject('i18n')
29
+ * const german: ITranslator = i18n.translator('de-DE')
30
+ * console.log(german.locale) //de-DE, its messages looked up in de-DE, then de, then en
31
+ * console.log(german.t('order.count', {count: 1000})) //1.000 Bestellungen
32
+ * console.log(german.number(1234.5)) //1.234,5
33
+ * })
34
+ * ```
35
+ */
36
+ export declare class LocaleTranslator implements ITranslator {
37
+ protected readonly i18n: I18n;
38
+ readonly locale: string;
39
+ /**
40
+ * Create the translator of a language; {@link I18n.translator} does it, normalizing the language first.
41
+ * @param i18n The component holding the messages.
42
+ * @param locale The language tag, canonical.
43
+ */
44
+ constructor(i18n: I18n, locale: string);
45
+ /**
46
+ * Give the message of a key in the translator's language, formatted with its arguments (see {@link I18n.t}).
47
+ * @param key The key of the message (`order.count`).
48
+ * @param params The arguments of the message, by their names.
49
+ * @returns The formatted message. With `missing: 'key'`, the key itself when it is missing, `{name}` in place of a
50
+ * missing argument, a plain number for a currency argument without a currency (each with a warning, once).
51
+ * @throws {I18nMissingKeyException} When no message has the key and the `missing` option is `'throw'`.
52
+ * @throws {I18nMissingParameterException} When an argument is missing, or a currency argument has no currency, and
53
+ * the `missing` option is `'throw'`.
54
+ * @throws {I18nMessageSyntaxException} When the message is not valid ICU MessageFormat.
55
+ */
56
+ t<K extends MessageKey>(key: K, params?: MessageParams<K>): string;
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: string): boolean;
63
+ /**
64
+ * Format an ICU MessageFormat message which is not in the catalogs, in the translator's language.
65
+ * @param message The message (`Hello, {name}`).
66
+ * @param params The arguments of the message, by their names.
67
+ * @returns The formatted message. With `missing: 'key'`, `{name}` in place of a missing argument, a plain number
68
+ * for a currency argument without a currency (each with a warning, once).
69
+ * @throws {I18nMessageSyntaxException} When the message is not valid ICU MessageFormat.
70
+ * @throws {I18nMissingParameterException} When an argument is missing, or a currency argument has no currency, and
71
+ * the `missing` option is `'throw'`.
72
+ */
73
+ format(message: string, params?: Record<string, unknown>): string;
74
+ /**
75
+ * Format a number in the translator's language (`1234.5` gives `1,234.5` in English, `1.234,5` in German).
76
+ * @param value The number.
77
+ * @param options The options of `Intl.NumberFormat` (`{style: 'currency', currency: 'EUR'}`); the component's
78
+ * `currency` option is not applied here.
79
+ * @returns The formatted number.
80
+ * @throws {RangeError} When the options are invalid (a currency style without its currency).
81
+ */
82
+ number(value: number | bigint, options?: Intl.NumberFormatOptions): string;
83
+ /**
84
+ * Format a date (and its time when the options ask for it) in the translator's language, in the component's
85
+ * `timeZone` (the process's one without it).
86
+ * @param value The date, or a timestamp in milliseconds.
87
+ * @param options The options of `Intl.DateTimeFormat` (`{dateStyle: 'long'}`); their `timeZone` replaces the
88
+ * component's one.
89
+ * @returns The formatted date.
90
+ * @throws {RangeError} When the date is invalid or the options are.
91
+ */
92
+ date(value: Date | number, options?: Intl.DateTimeFormatOptions): string;
93
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * A part of a message parsed by {@link parseMessage}: a literal text (a string, its quoting already removed), a plain
3
+ * argument (`{name}`), a number argument (`{n, number, percent}`), a date or time argument (`{at, date, short}`), a
4
+ * choice (`plural`, `selectordinal`, `select`), or the `#` of a plural (its number).
5
+ */
6
+ export type MessagePart = string | {
7
+ /**
8
+ * A plain argument, `{name}`: a number formatted in the language, a `Date` as a date, anything else as a string.
9
+ */
10
+ type: 'argument';
11
+ /**
12
+ * The name of the argument.
13
+ */
14
+ name: string;
15
+ } | {
16
+ /**
17
+ * A number argument, `{name, number}` or `{name, number, style}`.
18
+ */
19
+ type: 'number';
20
+ /**
21
+ * The name of the argument.
22
+ */
23
+ name: string;
24
+ /**
25
+ * The style: `integer`, `percent`, `currency` or `currency/XXX` (an ISO 4217 code); absent for a plain number.
26
+ */
27
+ style?: string;
28
+ } | {
29
+ /**
30
+ * A date argument (`{name, date, style}`) or a time argument (`{name, time, style}`).
31
+ */
32
+ type: 'date' | 'time';
33
+ /**
34
+ * The name of the argument.
35
+ */
36
+ name: string;
37
+ /**
38
+ * The style: `short`, `medium`, `long` or `full`; absent for `medium`.
39
+ */
40
+ style?: string;
41
+ } | {
42
+ /**
43
+ * A cardinal (`plural`) or ordinal (`selectordinal`) choice on a number argument.
44
+ */
45
+ type: 'plural' | 'selectordinal';
46
+ /**
47
+ * The name of the argument.
48
+ */
49
+ name: string;
50
+ /**
51
+ * The `offset:` of the choice, subtracted from the number before its plural rule and its `#`; 0 without it.
52
+ */
53
+ offset: number;
54
+ /**
55
+ * The messages by selector: `=0` (an exact value, matched before the offset), a plural category of the
56
+ * language (`zero`, `one`, `two`, `few`, `many`), and the required `other`.
57
+ */
58
+ options: Record<string, MessagePart[]>;
59
+ } | {
60
+ /**
61
+ * A choice on the string value of an argument, `{name, select, admin {...} other {...}}`.
62
+ */
63
+ type: 'select';
64
+ /**
65
+ * The name of the argument.
66
+ */
67
+ name: string;
68
+ /**
69
+ * The messages by value of the argument, and the required `other`.
70
+ */
71
+ options: Record<string, MessagePart[]>;
72
+ } | {
73
+ /**
74
+ * The `#` of a plural: the number of the closest enclosing `plural` or `selectordinal` (minus its offset),
75
+ * formatted in the language.
76
+ */
77
+ type: 'pound';
78
+ };
79
+ /**
80
+ * How a message is formatted
81
+ */
82
+ export type FormatContext = {
83
+ locale: string;
84
+ /**
85
+ * The currency of {price, number, currency} (ISO 4217)
86
+ */
87
+ currency?: string;
88
+ /**
89
+ * The time zone of the dates and the times (IANA)
90
+ */
91
+ timeZone?: string;
92
+ /**
93
+ * An argument or a currency missing: thrown, or kept (the argument as it is written, {name}; the number without
94
+ * currency) with a warning
95
+ */
96
+ missing: 'throw' | 'keep';
97
+ /**
98
+ * Told what is kept when missing is 'keep'
99
+ */
100
+ warn?: (warning: string) => void;
101
+ };
102
+ /**
103
+ * Parse an ICU MessageFormat message into its parts, to inspect it (its arguments: {@link messageArguments}). The
104
+ * syntax: `{name}`; `{name, number}` with the styles `integer`, `percent`, `currency`, `currency/XXX`; `{name, date}`
105
+ * and `{name, time}` with the styles `short`, `medium`, `long`, `full`; `{name, plural, ...}` (with `offset:n`),
106
+ * `{name, selectordinal, ...}` and `{name, select, ...}`, each requiring an `other` option; `#` in a plural; `''` for
107
+ * an apostrophe and `'{...}'` to quote the braces (and `#` in a plural). Other argument types and the skeletons
108
+ * (`::currency/EUR`) are not supported.
109
+ * @param message The message.
110
+ * @param source Where the message comes from (`zh-CN: order.count`), the prefix of the error messages: `'message'` by
111
+ * default.
112
+ * @returns The parts of the message.
113
+ * @throws {I18nMessageSyntaxException} When the message is not valid, with the source and the position.
114
+ * @example
115
+ * ```typescript
116
+ * import {parseMessage, type MessagePart} from 'lakutata/com/i18n'
117
+ *
118
+ * const parts: MessagePart[] = parseMessage('{count, plural, one {# order} other {# orders}} for {customer}')
119
+ * console.log(parts.length) //3
120
+ * ```
121
+ */
122
+ export declare function parseMessage(message: string, source?: string): MessagePart[];
123
+ /**
124
+ * List the arguments a parsed message uses, nested choices included, with the type of their first use: `argument`,
125
+ * `number`, `date`, `time`, `plural`, `selectordinal` or `select`.
126
+ * @param parts The parts of the message, from {@link parseMessage}.
127
+ * @returns The argument types by argument name, in their order in the message.
128
+ * @example
129
+ * ```typescript
130
+ * import {messageArguments, parseMessage} from 'lakutata/com/i18n'
131
+ *
132
+ * const args: Map<string, string> = messageArguments(parseMessage('{count, plural, other {# orders}} for {customer}'))
133
+ * console.log([...args]) //[['count', 'plural'], ['customer', 'argument']]
134
+ * ```
135
+ */
136
+ export declare function messageArguments(parts: MessagePart[]): Map<string, string>;
137
+ /**
138
+ * Format a parsed message
139
+ * @param parts
140
+ * @param params
141
+ * @param context
142
+ * @param source where the message is (its key), for the errors
143
+ */
144
+ export declare function formatMessage(parts: MessagePart[], params: Record<string, unknown>, context: FormatContext, source?: string): string;
@@ -0,0 +1,71 @@
1
+ import type { Module } from '@lakutata/core';
2
+ import type { CLITranslate } from '@lakutata/entrypoint/adapter/commander';
3
+ /**
4
+ * The options of {@link commanderTranslator}.
5
+ */
6
+ export type CommanderTranslatorOptions = {
7
+ /**
8
+ * The name the {@link I18n} component is registered under.
9
+ * @default 'i18n'
10
+ */
11
+ component?: string;
12
+ /**
13
+ * The prefix of the message keys of the texts: with `'cli'`, the description of the command `greet` is the message
14
+ * `cli.commands.greet.description`; `''` for no prefix.
15
+ * @default 'cli'
16
+ */
17
+ prefix?: string;
18
+ /**
19
+ * The name of an environment variable (`LAKUTATA_LANG`) whose language is preferred to the ones of the environment.
20
+ * @default none
21
+ */
22
+ environment?: string;
23
+ };
24
+ /**
25
+ * Translate the help and the prompts of the Commander CLI adapter with the messages of an {@link I18n} component: give
26
+ * it to the `translate` option of `commanderCLIEntrypoint()`. Each text is the message of its key under the prefix
27
+ * (`cli.description`, `cli.commands.<command>.description`, `cli.commands.<command>.arguments.<name>`,
28
+ * `cli.commands.<command>.options.<property>`, `cli.help.usage`, `cli.help.options`, `cli.help.default`,
29
+ * `cli.prompt.json`…), formatted with the text's parameters (`{value}`); a text without a message keeps its English
30
+ * text. The language is chosen once, when the adapter starts: the environment variable of the `environment` option,
31
+ * the environment ({@link environmentLocales}), then the process's language, negotiated with the component's
32
+ * supported languages. The names of the commands and of the options are never translated.
33
+ * @param options The component, the key prefix and the environment variable.
34
+ * @returns The `translate` function of the adapter, which gets the component from the application's module.
35
+ * @example
36
+ * ```typescript
37
+ * import {Application} from 'lakutata'
38
+ * import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
39
+ * import {commanderCLIEntrypoint} from 'lakutata/adapter/commander'
40
+ * import {CLIAction} from 'lakutata/decorator/ctrl'
41
+ * import {buildI18nOptions, commanderTranslator, I18nMiddleware} from 'lakutata/com/i18n'
42
+ *
43
+ * class GreetController extends Controller {
44
+ * @CLIAction('greet', {description: 'Greet someone'})
45
+ * public async greet(): Promise<string> {
46
+ * return 'Hello'
47
+ * }
48
+ * }
49
+ *
50
+ * Application.run(() => ({
51
+ * id: 'tool.app',
52
+ * name: 'Tool',
53
+ * components: {
54
+ * i18n: buildI18nOptions({
55
+ * messages: {
56
+ * 'zh-CN': {cli: {commands: {greet: {description: '问候某人'}}, help: {usage: '用法:', options: '选项:'}}}
57
+ * }
58
+ * }),
59
+ * entrypoint: buildEntrypoints({
60
+ * controllers: [GreetController],
61
+ * //The controllers' Translator in the same language
62
+ * middlewares: [I18nMiddleware],
63
+ * //LANG=zh_CN.UTF-8 tool --help: the help in Chinese
64
+ * cli: commanderCLIEntrypoint({bin: 'tool', translate: commanderTranslator({environment: 'TOOL_LANG'})})
65
+ * })
66
+ * },
67
+ * bootstrap: ['entrypoint']
68
+ * }))
69
+ * ```
70
+ */
71
+ export declare function commanderTranslator(options?: CommanderTranslatorOptions): (module: Module) => Promise<CLITranslate>;
@@ -0,0 +1,126 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import { Middleware, type ActionInvocation, type BaseContext, type Next } from '@lakutata/entrypoint';
3
+ import type { I18n } from '../I18n.js';
4
+ import { Translator } from '../Translator.js';
5
+ /**
6
+ * The entrypoint middleware choosing the language of each request. Add it to the `middlewares` of
7
+ * `buildEntrypoints()`. For each request, it reads the languages the request asks for ({@link I18nMiddleware.requested}),
8
+ * negotiates them with the supported languages of the {@link I18n} component ({@link I18n.negotiate}, the default
9
+ * language when none matches), and makes the result:
10
+ * - the request's `context.locale`;
11
+ * - the language of the {@link Translator} the request's objects inject;
12
+ * - the language of the validation errors of the request (its input, `@Accept`/`@Return`, the `DTO.validate` calls of
13
+ * its code);
14
+ * - the language of the exceptions it throws: a lakutata `Exception` whose `errno` has a message `errors.<errno>`
15
+ * (`errors.E_ORDER_NOT_FOUND`) gets that message, formatted with the exception's template parameters.
16
+ *
17
+ * The languages are read, preferred first: for HTTP, the `lang` query parameter, the `lang` cookie, then
18
+ * `Accept-Language`; for Socket.IO, the same from the connection's handshake; for the HTTP service adapter and the
19
+ * JSON-RPC adapter over HTTP or WebSocket, the same from the HTTP request (for WebSocket, the request opening the
20
+ * connection); for the command line and the other service transports, the process's environment
21
+ * ({@link environmentLocales}) then the process's language; for the schedules, none. It uses the component registered as `i18n`; a subclass changes the component, the names of the query
22
+ * parameter and of the cookie, or the languages read (`requested()`).
23
+ * @example
24
+ * ```typescript
25
+ * import {type BaseContext} from 'lakutata/com/entrypoint'
26
+ * import {Inject} from 'lakutata/decorator/di'
27
+ * import {I18nMiddleware, type I18n} from 'lakutata/com/i18n'
28
+ *
29
+ * //BuildEntrypoints({middlewares: [LanguageMiddleware], ...}) in place of I18nMiddleware
30
+ * export class LanguageMiddleware extends I18nMiddleware {
31
+ * //The component registered as "lang"
32
+ * @Inject('lang')
33
+ * protected readonly i18n: I18n
34
+ *
35
+ * //?locale=zh-CN
36
+ * protected readonly queryParameter: string = 'locale'
37
+ *
38
+ * protected readonly environmentVariable: string = 'SHOP_LANG'
39
+ *
40
+ * //The language saved by the signed-in user first
41
+ * protected requested(context: BaseContext): string[] {
42
+ * const saved: unknown = context.data.userLanguage
43
+ * return [...(typeof saved === 'string' ? [saved] : []), ...super.requested(context)]
44
+ * }
45
+ * }
46
+ * ```
47
+ */
48
+ export declare class I18nMiddleware extends Middleware {
49
+ /**
50
+ * The {@link I18n} component giving the supported languages and the messages: the object registered as `i18n`.
51
+ */
52
+ protected readonly i18n: I18n;
53
+ /**
54
+ * The name of the query parameter giving the language of an HTTP request (of the HTTP channel, the HTTP service and
55
+ * JSON-RPC adapters) or a Socket.IO connection (`?lang=zh-CN`); a value which is not a language tag is ignored.
56
+ * @default 'lang'
57
+ */
58
+ protected readonly queryParameter: string;
59
+ /**
60
+ * The name of the cookie giving the language of an HTTP request (of the HTTP channel, the HTTP service and JSON-RPC
61
+ * adapters) or a Socket.IO connection; a value which is not a language tag is ignored.
62
+ * @default 'lang'
63
+ */
64
+ protected readonly cookie: string;
65
+ /**
66
+ * The name of an environment variable (`SHOP_LANG`) whose language is preferred to the ones of the environment, for
67
+ * the command line and the service transports reading the process's languages.
68
+ * @default none
69
+ */
70
+ protected readonly environmentVariable?: string;
71
+ /**
72
+ * Negotiate the request's language, set `context.locale`, create the request's {@link Translator}, then run the
73
+ * next middlewares and the action with the validations in that language, translating the exception they throw.
74
+ * @param invocation The action invocation: its context and its container.
75
+ * @param next Runs the rest of the chain.
76
+ * @returns The result of the rest of the chain; an exception it throws is rethrown, its message translated by
77
+ * {@link I18nMiddleware.translate}.
78
+ */
79
+ handle(invocation: ActionInvocation, next: Next): Promise<unknown>;
80
+ /**
81
+ * Translate an exception thrown by a request into the request's language. A lakutata `Exception` with an `errno`
82
+ * whose key `errors.<errno>` has a message (`errors.E_ORDER_NOT_FOUND`) gets that message as its `message` and
83
+ * `errMsg`, formatted with the parameters of its template (`{id}` for named ones, `{0}`, `{1}` for the ones given one
84
+ * by one); its `errno` and its other properties are kept. The other errors, the exceptions without such a message,
85
+ * and the ones whose message cannot be formatted (a process warning `I18nWarning` is then emitted) are returned
86
+ * unchanged.
87
+ * @param error The thrown value.
88
+ * @param translator The request's translator.
89
+ * @returns The same value, its message replaced when it is translated.
90
+ */
91
+ protected translate(error: unknown, translator: Translator): unknown;
92
+ /**
93
+ * Read the languages a request asks for, preferred first:
94
+ * - HTTP: the query parameter, the cookie, then `Accept-Language`;
95
+ * - Socket.IO: the query parameter, the cookie and `Accept-Language` of the connection's handshake;
96
+ * - the HTTP service adapter, and the JSON-RPC adapter over HTTP or WebSocket: the query parameter, the cookie and
97
+ * `Accept-Language` of the HTTP request (for WebSocket, the request opening the connection);
98
+ * - the command line and the other service transports (Electron IPC, JSON-RPC over stdio): the environment variable
99
+ * of `environmentVariable`, the environment (`LANGUAGE`, `LC_ALL`, `LC_MESSAGES`, `LANG`), then the process's
100
+ * language;
101
+ * - the schedules and the other contexts: none (the default language is chosen).
102
+ * @param context The request's context.
103
+ * @returns The requested language tags, preferred first.
104
+ */
105
+ protected requested(context: BaseContext): string[];
106
+ /**
107
+ * Read the languages of an HTTP request: its query parameter, its cookie, then its `Accept-Language`.
108
+ * @param request The Node.js HTTP request.
109
+ * @returns The language tags, preferred first.
110
+ */
111
+ protected fromRequest(request: IncomingMessage): string[];
112
+ /**
113
+ * Read the languages of a query parameter, the cookie and `Accept-Language`, in that order; the query and cookie
114
+ * values which are not language tags are skipped.
115
+ * @param query The value of the query parameter; ignored unless it is a string.
116
+ * @param headers The request headers, by lower-case name; for a repeated header, its first value is read.
117
+ * @returns The language tags, preferred first.
118
+ */
119
+ protected fromHeaders(query: unknown, headers: Record<string, string | string[] | undefined>): string[];
120
+ /**
121
+ * Read the value of the language cookie (named by `cookie`) in a `Cookie` header, URI-decoded.
122
+ * @param header The value of the `Cookie` header.
123
+ * @returns The value of the first cookie of that name; `undefined` without it, or when it cannot be decoded.
124
+ */
125
+ protected cookieValue(header: string | undefined): string | undefined;
126
+ }