lino-i18n 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # lino-i18n Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8e331fd: Add reactive runtime subscriptions and first-class React bindings with provider,
8
+ translation hooks, rich interpolation, locale selection, and Intl formatters.
9
+
10
+ ## 0.1.1
11
+
12
+ ### Patch Changes
13
+
14
+ - 22cf52e: Document the Hive Mind deep catalogue authoring pattern and keep JS examples aligned.
15
+
16
+ ## 0.1.0
17
+
18
+ ### Minor Changes
19
+
20
+ - 4c3b133: Add configurable compatibility aliases for deeper nested migration keys.
21
+
22
+ ## 0.0.2
23
+
24
+ ### Patch Changes
25
+
26
+ - 29f0d6f: Preserve scalar parent translations as `label` children when formatting nested
27
+ catalogues, and resolve `foo` from `foo.label` when no explicit `foo`
28
+ translation exists.
29
+
3
30
  ## 0.0.1
4
31
 
5
32
  Initial release of the JavaScript `lino-i18n` package.
package/README.md CHANGED
@@ -30,18 +30,79 @@ i18n.t('greeting', { name: 'World' }); // → "Hello, World!"
30
30
  i18n.t('cart.items', { count: 0 }); // → "Your cart is empty"
31
31
  i18n.t('cart.items', { count: 3 }, { locale: 'ru' }); // → "3 товара"
32
32
  i18n.t('role', { context: 'female' }); // → "She is a developer"
33
+ i18n.t('telegram.help.solve.alias.detail'); // → "Tool aliases imply `--tool <tool>`"
33
34
  ```
34
35
 
36
+ ## React
37
+
38
+ React is an optional peer dependency. The `lino-i18n/react` adapter uses the
39
+ same `.lino` catalogues as the framework-independent runtime:
40
+
41
+ ```jsx
42
+ import { createI18n } from 'lino-i18n';
43
+ import {
44
+ CurrencyFormat,
45
+ I18nProvider,
46
+ LocaleSelector,
47
+ Trans,
48
+ useTranslation,
49
+ } from 'lino-i18n/react';
50
+
51
+ const i18n = createI18n({ locales: catalogues, defaultLocale: 'en' });
52
+
53
+ function Checkout() {
54
+ const { t } = useTranslation('checkout');
55
+ return (
56
+ <>
57
+ <h1>{t('title')}</h1>
58
+ <Trans
59
+ id="checkout.total"
60
+ values={{ amount: <CurrencyFormat value={19.99} currency="USD" /> }}
61
+ />
62
+ <LocaleSelector labels={{ en: 'English', fr: 'Français' }} />
63
+ </>
64
+ );
65
+ }
66
+
67
+ export default function App() {
68
+ return (
69
+ <I18nProvider i18n={i18n}>
70
+ <Checkout />
71
+ </I18nProvider>
72
+ );
73
+ }
74
+ ```
75
+
76
+ `useI18n`, `useLocale`, and `useTranslation` update when `setLocale`,
77
+ `addLocale`, or an asynchronous catalogue loader changes the instance.
78
+ `NumberFormat`, `DateTimeFormat`, `CurrencyFormat`, and `RelativeTimeFormat`
79
+ format values with the active locale through the platform `Intl` APIs.
80
+
35
81
  A sample `.lino` catalogue looks like this:
36
82
 
37
83
  ```lino
38
84
  en
39
85
  greeting "Hello, {{name}}!"
40
- hero
41
- description """
42
- Keep each language in its own block, nest related messages together,
43
- and still resolve the same runtime keys.
44
- """
86
+ telegram
87
+ help
88
+ title "Help"
89
+ solve
90
+ alias
91
+ detail "Tool aliases imply `--tool <tool>`"
92
+ prompt
93
+ system
94
+ general
95
+ guidelines
96
+ header "General guidelines."
97
+ body """
98
+ When you start, create a detailed plan for yourself.
99
+ Follow your todo list step by step.
100
+ """
101
+ error
102
+ label "Error"
103
+ invalid
104
+ github
105
+ url "Error: Invalid GitHub URL format"
45
106
  cart
46
107
  title "Your cart"
47
108
  items
@@ -54,9 +115,36 @@ en
54
115
  other "They are a developer"
55
116
  ```
56
117
 
57
- Nested plural and context groups flatten to the runtime suffix keys
58
- `cart.items_one`, `cart.items_other`, and `role_female`. A single file may also
59
- contain several top-level locale blocks, for example `en` followed by `ru`.
118
+ Deeply nested blocks flatten to canonical dot keys such as
119
+ `telegram.help.solve.alias.detail` and
120
+ `prompt.system.general.guidelines.body`. Nested plural and context groups still
121
+ flatten to runtime suffix keys such as `cart.items_one`, `cart.items_other`,
122
+ and `role_female`. A single file may also contain several top-level locale
123
+ blocks, for example `en` followed by `ru`.
124
+
125
+ Use a `label` child when a translated group also needs its own runtime key:
126
+ `error.label` and `error` both resolve to `"Error"`, and an explicit `error`
127
+ translation wins over the generated alias.
128
+
129
+ For migrations from flatter catalogues, enable compatibility aliases when
130
+ loading or creating the runtime:
131
+
132
+ ```js
133
+ const catalogues = await loadLocalesFromDirectory('./locales', {
134
+ compatibilityAliases: ['collapseTail', 'parentLabel'],
135
+ });
136
+ const i18n = createI18n({
137
+ locales: catalogues,
138
+ defaultLocale: 'en',
139
+ });
140
+ ```
141
+
142
+ `collapseTail` exposes underscore-tail aliases for deeper keys, so
143
+ `telegram.help.solve.alias.detail` also resolves through
144
+ `telegram.help_solve_alias_detail`, `telegram.help.solve_alias_detail`, and
145
+ `telegram.help.solve.alias_detail`. `parentLabel` maps `error.label` to the
146
+ legacy parent key `error`. Generated aliases never overwrite explicit
147
+ translations.
60
148
 
61
149
  ## CLI
62
150
 
@@ -90,7 +178,9 @@ Run `npx lino-i18n --help` for every option.
90
178
  - `{{var}}` and `{var}` placeholder syntax for compatibility with i18next
91
179
  and `react-intl`.
92
180
  - Context (gender) suffixes: `role_male`, `role_female`, `role_other`.
181
+ - Migration aliases for deeper nested keys and parent labels.
93
182
  - Namespace prefixes via `:` (`navigation:home`) and `.` (`cart.title`).
183
+ - Group label aliases via `label` children.
94
184
  - Configurable fallback chain.
95
185
  - Bundled multi-locale `.lino` files and per-language directories.
96
186
  - Optional missing-key handler.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lino-i18n",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "description": "Universal i18n library that stores translations in Links Notation (.lino) instead of JSON.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -21,7 +21,11 @@
21
21
  "import": "./src/index.js"
22
22
  },
23
23
  "./converters": "./src/converters/index.js",
24
- "./loaders": "./src/loaders.js"
24
+ "./loaders": "./src/loaders.js",
25
+ "./react": {
26
+ "types": "./src/react.d.ts",
27
+ "import": "./src/react.js"
28
+ }
25
29
  },
26
30
  "scripts": {
27
31
  "test": "node --test --test-timeout=30000 tests/*.test.js",
@@ -69,11 +73,23 @@
69
73
  "devDependencies": {
70
74
  "@changesets/cli": "^2.31.0",
71
75
  "@eslint/js": "^10.0.1",
76
+ "@types/node": "^26.1.1",
77
+ "@types/react": "^19.2.17",
72
78
  "eslint": "^10.4.0",
73
79
  "eslint-config-prettier": "^10.1.8",
74
80
  "eslint-plugin-prettier": "^5.5.5",
75
81
  "jscpd": "^4.2.2",
76
82
  "prettier": "^3.8.3",
83
+ "react": "^19.1.1",
84
+ "react-test-renderer": "^19.1.1",
77
85
  "test-anywhere": "^0.9.1"
86
+ },
87
+ "peerDependencies": {
88
+ "react": ">=18.0.0"
89
+ },
90
+ "peerDependenciesMeta": {
91
+ "react": {
92
+ "optional": true
93
+ }
78
94
  }
79
95
  }
@@ -0,0 +1,85 @@
1
+ // Compatibility helpers for catalogue migrations. These helpers work on the
2
+ // flat key tables produced by the loader and only add aliases for keys that do
3
+ // not already exist.
4
+
5
+ const ALIAS_NAMES = new Map([
6
+ ['collapseTail', 'collapseTail'],
7
+ ['collapse-tail', 'collapseTail'],
8
+ ['parentLabel', 'parentLabel'],
9
+ ['parent-label', 'parentLabel'],
10
+ ]);
11
+
12
+ function toAliasList(options = {}) {
13
+ if (Array.isArray(options) || typeof options === 'string') {
14
+ return Array.isArray(options) ? options : [options];
15
+ }
16
+ const requested = options.compatibilityAliases ?? options.mode ?? [];
17
+ return Array.isArray(requested) ? requested : [requested];
18
+ }
19
+
20
+ export function normalizeCompatibilityAliases(options = {}) {
21
+ const normalized = [];
22
+ for (const alias of toAliasList(options)) {
23
+ if (!alias) {
24
+ continue;
25
+ }
26
+ const name = ALIAS_NAMES.get(String(alias));
27
+ if (!name) {
28
+ throw new TypeError(`unknown compatibility alias mode: ${alias}`);
29
+ }
30
+ if (!normalized.includes(name)) {
31
+ normalized.push(name);
32
+ }
33
+ }
34
+ return normalized;
35
+ }
36
+
37
+ function collapseTailAliases(key) {
38
+ const parts = key.split('.');
39
+ if (parts.length < 3) {
40
+ return [];
41
+ }
42
+ const aliases = [];
43
+ for (let index = 1; index < parts.length - 1; index += 1) {
44
+ aliases.push(
45
+ `${parts.slice(0, index).join('.')}.${parts.slice(index).join('_')}`
46
+ );
47
+ }
48
+ return aliases;
49
+ }
50
+
51
+ function parentLabelAlias(key) {
52
+ if (!key.endsWith('.label')) {
53
+ return null;
54
+ }
55
+ const parent = key.slice(0, -'.label'.length);
56
+ return parent || null;
57
+ }
58
+
59
+ export function expandCompatibilityAliases(translations, options = {}) {
60
+ const aliases = normalizeCompatibilityAliases(options);
61
+ const expanded = { ...(translations || {}) };
62
+ if (aliases.length === 0) {
63
+ return expanded;
64
+ }
65
+
66
+ const hasOwn = Object.prototype.hasOwnProperty;
67
+ for (const [key, value] of Object.entries(translations || {})) {
68
+ if (aliases.includes('collapseTail')) {
69
+ for (const alias of collapseTailAliases(key)) {
70
+ if (!hasOwn.call(expanded, alias)) {
71
+ expanded[alias] = value;
72
+ }
73
+ }
74
+ }
75
+
76
+ if (aliases.includes('parentLabel')) {
77
+ const alias = parentLabelAlias(key);
78
+ if (alias && !hasOwn.call(expanded, alias)) {
79
+ expanded[alias] = value;
80
+ }
81
+ }
82
+ }
83
+
84
+ return expanded;
85
+ }
package/src/format.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // resolution algorithm shared between the runtime and the CLI.
4
4
 
5
5
  const INTERPOLATION_PATTERN = /\{\{?\s*([\w.$:-]+)\s*\}?\}/g;
6
+ const LABEL_ALIAS_KEY = 'label';
6
7
 
7
8
  const PLURAL_SUFFIXES = ['zero', 'one', 'two', 'few', 'many', 'other'];
8
9
 
@@ -100,6 +101,10 @@ export function resolveKey(table, key, { count, context, locale } = {}) {
100
101
  if (direct !== undefined) {
101
102
  return direct;
102
103
  }
104
+ const labelAlias = lookup(table, `${target}.${LABEL_ALIAS_KEY}`);
105
+ if (labelAlias !== undefined) {
106
+ return labelAlias;
107
+ }
103
108
  }
104
109
 
105
110
  if (context) {
@@ -112,4 +117,8 @@ export function resolveKey(table, key, { count, context, locale } = {}) {
112
117
  return undefined;
113
118
  }
114
119
 
115
- export const _internals = { PLURAL_SUFFIXES, INTERPOLATION_PATTERN };
120
+ export const _internals = {
121
+ PLURAL_SUFFIXES,
122
+ INTERPOLATION_PATTERN,
123
+ LABEL_ALIAS_KEY,
124
+ };
package/src/i18n.js CHANGED
@@ -8,6 +8,10 @@ import {
8
8
  loadLocalesFromFile,
9
9
  loadLocalesFromDirectory,
10
10
  } from './loaders.js';
11
+ import {
12
+ expandCompatibilityAliases,
13
+ normalizeCompatibilityAliases,
14
+ } from './compatibility.js';
11
15
  import { interpolate, resolveKey } from './format.js';
12
16
 
13
17
  function normalizeFallbacks(fallback) {
@@ -27,16 +31,46 @@ export function createI18n(options = {}) {
27
31
  fallback = ['en'],
28
32
  onMissingKey,
29
33
  interpolation = { prefix: '{{', suffix: '}}' },
34
+ compatibilityAliases: requestedCompatibilityAliases = [],
30
35
  } = options;
36
+ const compatibilityAliases = normalizeCompatibilityAliases(
37
+ requestedCompatibilityAliases
38
+ );
31
39
 
32
40
  const catalogues = new Map();
33
41
  for (const [locale, translations] of Object.entries(locales)) {
34
- catalogues.set(locale, { ...translations });
42
+ catalogues.set(
43
+ locale,
44
+ expandCompatibilityAliases(translations, { compatibilityAliases })
45
+ );
35
46
  }
36
47
 
37
48
  let currentLocale = defaultLocale;
49
+ let revision = 0;
50
+ const listeners = new Set();
38
51
  const fallbacks = normalizeFallbacks(fallback);
39
52
 
53
+ function notify() {
54
+ revision += 1;
55
+ for (const listener of listeners) {
56
+ listener();
57
+ }
58
+ }
59
+
60
+ function subscribe(listener) {
61
+ if (typeof listener !== 'function') {
62
+ throw new TypeError('subscribe expects a function');
63
+ }
64
+ listeners.add(listener);
65
+ return () => {
66
+ listeners.delete(listener);
67
+ };
68
+ }
69
+
70
+ function getRevision() {
71
+ return revision;
72
+ }
73
+
40
74
  function getLocale() {
41
75
  return currentLocale;
42
76
  }
@@ -45,7 +79,10 @@ export function createI18n(options = {}) {
45
79
  if (typeof locale !== 'string' || !locale) {
46
80
  throw new TypeError('setLocale expects a non-empty string locale');
47
81
  }
48
- currentLocale = locale;
82
+ if (locale !== currentLocale) {
83
+ currentLocale = locale;
84
+ notify();
85
+ }
49
86
  }
50
87
 
51
88
  function getFallbacks() {
@@ -58,7 +95,7 @@ export function createI18n(options = {}) {
58
95
 
59
96
  function has(key, locale = currentLocale) {
60
97
  const table = catalogues.get(locale);
61
- return Boolean(table && Object.prototype.hasOwnProperty.call(table, key));
98
+ return resolveKey(table, key, { locale }) !== undefined;
62
99
  }
63
100
 
64
101
  function _lookup(key, opts) {
@@ -117,7 +154,14 @@ export function createI18n(options = {}) {
117
154
  throw new TypeError('addLocale requires a string locale name');
118
155
  }
119
156
  const current = catalogues.get(locale) || {};
120
- catalogues.set(locale, { ...current, ...translations });
157
+ catalogues.set(
158
+ locale,
159
+ expandCompatibilityAliases(
160
+ { ...current, ...translations },
161
+ { compatibilityAliases }
162
+ )
163
+ );
164
+ notify();
121
165
  }
122
166
 
123
167
  async function loadLocale(locale, text) {
@@ -157,5 +201,7 @@ export function createI18n(options = {}) {
157
201
  loadLocaleFile,
158
202
  loadDirectory,
159
203
  interpolation,
204
+ subscribe,
205
+ getRevision,
160
206
  };
161
207
  }
package/src/index.d.ts CHANGED
@@ -1,5 +1,18 @@
1
1
  // Type declarations for the `lino-i18n` package.
2
2
 
3
+ export type CompatibilityAlias =
4
+ | 'collapseTail'
5
+ | 'collapse-tail'
6
+ | 'parentLabel'
7
+ | 'parent-label';
8
+
9
+ export interface CompatibilityAliasOptions {
10
+ /** Alias modes used to expose migration keys without overwriting explicit keys. */
11
+ compatibilityAliases?: CompatibilityAlias | CompatibilityAlias[];
12
+ /** Alias mode shortcut for helper-style calls. */
13
+ mode?: CompatibilityAlias | CompatibilityAlias[];
14
+ }
15
+
3
16
  export interface I18nOptions {
4
17
  /** Translation catalogues keyed by locale code. */
5
18
  locales?: Record<string, Record<string, string>>;
@@ -15,6 +28,8 @@ export interface I18nOptions {
15
28
  }) => string | void;
16
29
  /** Interpolation tokens; currently informational only. */
17
30
  interpolation?: { prefix?: string; suffix?: string };
31
+ /** Compatibility aliases generated from canonical keys during migration. */
32
+ compatibilityAliases?: CompatibilityAlias | CompatibilityAlias[];
18
33
  }
19
34
 
20
35
  export interface TOptions {
@@ -47,19 +62,39 @@ export interface I18nInstance {
47
62
  loadLocaleFile(filePath: string): Promise<string>;
48
63
  loadDirectory(directory: string): Promise<string[]>;
49
64
  interpolation: { prefix?: string; suffix?: string };
65
+ subscribe(listener: () => void): () => void;
66
+ getRevision(): number;
50
67
  }
51
68
 
52
69
  export declare function createI18n(options?: I18nOptions): I18nInstance;
53
70
 
71
+ export declare function expandCompatibilityAliases(
72
+ translations: Record<string, string>,
73
+ options?: CompatibilityAliasOptions
74
+ ): Record<string, string>;
75
+
54
76
  export declare function parseLinoCatalog(text: string): {
55
77
  locale: string | null;
56
78
  translations: Record<string, string>;
57
79
  };
58
80
 
81
+ export declare function parseLinoCatalog(
82
+ text: string,
83
+ options: CompatibilityAliasOptions
84
+ ): {
85
+ locale: string | null;
86
+ translations: Record<string, string>;
87
+ };
88
+
59
89
  export declare function parseLinoCatalogs(
60
90
  text: string
61
91
  ): Array<{ locale: string | null; translations: Record<string, string> }>;
62
92
 
93
+ export declare function parseLinoCatalogs(
94
+ text: string,
95
+ options: CompatibilityAliasOptions
96
+ ): Array<{ locale: string | null; translations: Record<string, string> }>;
97
+
63
98
  export declare function formatLinoCatalog(
64
99
  locale: string,
65
100
  translations: Record<string, string>,
@@ -75,19 +110,23 @@ export declare function formatLinoCatalogs(
75
110
 
76
111
  export declare function loadLocaleFromString(
77
112
  locale: string,
78
- text: string
113
+ text: string,
114
+ options?: CompatibilityAliasOptions
79
115
  ): Promise<{ locale: string; translations: Record<string, string> }>;
80
116
 
81
117
  export declare function loadLocaleFromFile(
82
- filePath: string
118
+ filePath: string,
119
+ options?: CompatibilityAliasOptions
83
120
  ): Promise<{ locale: string; translations: Record<string, string> }>;
84
121
 
85
122
  export declare function loadLocalesFromFile(
86
- filePath: string
123
+ filePath: string,
124
+ options?: CompatibilityAliasOptions
87
125
  ): Promise<Array<{ locale: string; translations: Record<string, string> }>>;
88
126
 
89
127
  export declare function loadLocalesFromDirectory(
90
- directory: string
128
+ directory: string,
129
+ options?: CompatibilityAliasOptions
91
130
  ): Promise<Record<string, Record<string, string>>>;
92
131
 
93
132
  export declare function interpolate(
package/src/index.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // framework.
9
9
 
10
10
  export { createI18n } from './i18n.js';
11
+ export { expandCompatibilityAliases } from './compatibility.js';
11
12
  export {
12
13
  parseLinoCatalog,
13
14
  parseLinoCatalogs,
package/src/loaders.js CHANGED
@@ -7,6 +7,8 @@
7
7
  import { promises as fs } from 'node:fs';
8
8
  import path from 'node:path';
9
9
 
10
+ import { expandCompatibilityAliases } from './compatibility.js';
11
+
10
12
  const SELECTOR_SUFFIXES = new Set([
11
13
  'zero',
12
14
  'one',
@@ -19,6 +21,8 @@ const SELECTOR_SUFFIXES = new Set([
19
21
  'neutral',
20
22
  ]);
21
23
 
24
+ const LABEL_ALIAS_KEY = 'label';
25
+
22
26
  function unescapeValue(value, quote = '"') {
23
27
  let result = '';
24
28
  for (let index = 0; index < value.length; index += 1) {
@@ -275,7 +279,9 @@ function isPlainObject(value) {
275
279
  }
276
280
 
277
281
  function isSelectorGroup(value) {
278
- const entries = Object.entries(value);
282
+ const entries = Object.entries(value).filter(
283
+ ([key]) => key !== LABEL_ALIAS_KEY
284
+ );
279
285
  return (
280
286
  entries.length > 0 &&
281
287
  entries.every(
@@ -284,6 +290,18 @@ function isSelectorGroup(value) {
284
290
  );
285
291
  }
286
292
 
293
+ function labelAliasValue(value) {
294
+ return typeof value[LABEL_ALIAS_KEY] === 'string'
295
+ ? value[LABEL_ALIAS_KEY]
296
+ : undefined;
297
+ }
298
+
299
+ function addLabelAlias(out, base, value) {
300
+ if (!Object.prototype.hasOwnProperty.call(out, base)) {
301
+ out[base] = value;
302
+ }
303
+ }
304
+
287
305
  function flattenTree(tree, pathParts = [], out = {}) {
288
306
  for (const [key, value] of Object.entries(tree)) {
289
307
  if (typeof value === 'string') {
@@ -295,14 +313,25 @@ function flattenTree(tree, pathParts = [], out = {}) {
295
313
  }
296
314
 
297
315
  const nextPath = [...pathParts, key];
316
+ const base = nextPath.join('.');
317
+ const labelValue = labelAliasValue(value);
298
318
  if (isSelectorGroup(value)) {
299
- const base = nextPath.join('.');
319
+ if (labelValue !== undefined) {
320
+ out[`${base}.${LABEL_ALIAS_KEY}`] = labelValue;
321
+ addLabelAlias(out, base, labelValue);
322
+ }
300
323
  for (const [suffix, child] of Object.entries(value)) {
324
+ if (suffix === LABEL_ALIAS_KEY) {
325
+ continue;
326
+ }
301
327
  out[`${base}_${suffix}`] = child;
302
328
  }
303
329
  continue;
304
330
  }
305
331
  flattenTree(value, nextPath, out);
332
+ if (labelValue !== undefined) {
333
+ addLabelAlias(out, base, labelValue);
334
+ }
306
335
  }
307
336
  return out;
308
337
  }
@@ -322,12 +351,23 @@ function splitSelectorSuffix(key) {
322
351
  function setNestedValue(tree, parts, value) {
323
352
  let node = tree;
324
353
  for (const part of parts.slice(0, -1)) {
325
- if (!isPlainObject(node[part])) {
354
+ const current = node[part];
355
+ if (!isPlainObject(current)) {
326
356
  node[part] = {};
357
+ if (typeof current === 'string') {
358
+ node[part][LABEL_ALIAS_KEY] = current;
359
+ }
327
360
  }
328
361
  node = node[part];
329
362
  }
330
- node[parts[parts.length - 1]] = value;
363
+ const leaf = parts[parts.length - 1];
364
+ if (isPlainObject(node[leaf])) {
365
+ if (!Object.prototype.hasOwnProperty.call(node[leaf], LABEL_ALIAS_KEY)) {
366
+ node[leaf][LABEL_ALIAS_KEY] = value;
367
+ }
368
+ return;
369
+ }
370
+ node[leaf] = value;
331
371
  }
332
372
 
333
373
  function translationsToTree(translations) {
@@ -360,7 +400,12 @@ function formatValue(value, indent) {
360
400
 
361
401
  function formatTreeLines(tree, indent = ' ') {
362
402
  const lines = [];
363
- for (const [key, value] of Object.entries(tree)) {
403
+ const entries = Object.entries(tree);
404
+ const labelEntry = entries.find(([key]) => key === LABEL_ALIAS_KEY);
405
+ const orderedEntries = labelEntry
406
+ ? [labelEntry, ...entries.filter(([key]) => key !== LABEL_ALIAS_KEY)]
407
+ : entries;
408
+ for (const [key, value] of orderedEntries) {
364
409
  if (typeof value === 'string') {
365
410
  lines.push(`${indent}${key} ${formatValue(value, indent)}`);
366
411
  continue;
@@ -386,16 +431,16 @@ function formatFlatCatalog(locale, translations) {
386
431
  // Parse the contents of one `.lino` catalogue. Returns the first
387
432
  // `{ locale, translations }` pair when the file contains multiple locale
388
433
  // roots. Use `parseLinoCatalogs` to keep every root.
389
- export function parseLinoCatalog(text) {
390
- const catalogues = parseLinoCatalogs(text);
434
+ export function parseLinoCatalog(text, options = {}) {
435
+ const catalogues = parseLinoCatalogs(text, options);
391
436
  return catalogues[0] || { locale: null, translations: {} };
392
437
  }
393
438
 
394
439
  // Parse every top-level locale block in a `.lino` string.
395
- export function parseLinoCatalogs(text) {
440
+ export function parseLinoCatalogs(text, options = {}) {
396
441
  return parseLocaleTrees(text).map(({ locale, tree }) => ({
397
442
  locale: locale || null,
398
- translations: flattenTree(tree),
443
+ translations: expandCompatibilityAliases(flattenTree(tree), options),
399
444
  }));
400
445
  }
401
446
 
@@ -427,8 +472,8 @@ export function formatLinoCatalogs(catalogues, options = {}) {
427
472
  .join('\n\n');
428
473
  }
429
474
 
430
- export async function loadLocaleFromString(locale, text) {
431
- const parsedCatalogues = parseLinoCatalogs(text);
475
+ export async function loadLocaleFromString(locale, text, options = {}) {
476
+ const parsedCatalogues = parseLinoCatalogs(text, options);
432
477
  const parsed = parsedCatalogues.find(
433
478
  (catalogue) => catalogue.locale === locale
434
479
  ) ||
@@ -439,17 +484,17 @@ export async function loadLocaleFromString(locale, text) {
439
484
  };
440
485
  }
441
486
 
442
- export async function loadLocaleFromFile(filePath) {
487
+ export async function loadLocaleFromFile(filePath, options = {}) {
443
488
  const text = await fs.readFile(filePath, 'utf8');
444
- const parsed = parseLinoCatalog(text);
489
+ const parsed = parseLinoCatalog(text, options);
445
490
  const locale =
446
491
  parsed.locale || path.basename(filePath, path.extname(filePath));
447
492
  return { locale, translations: parsed.translations };
448
493
  }
449
494
 
450
- export async function loadLocalesFromFile(filePath) {
495
+ export async function loadLocalesFromFile(filePath, options = {}) {
451
496
  const text = await fs.readFile(filePath, 'utf8');
452
- const parsed = parseLinoCatalogs(text);
497
+ const parsed = parseLinoCatalogs(text, options);
453
498
  if (parsed.length > 0) {
454
499
  return parsed;
455
500
  }
@@ -461,7 +506,7 @@ export async function loadLocalesFromFile(filePath) {
461
506
  ];
462
507
  }
463
508
 
464
- export async function loadLocalesFromDirectory(directory) {
509
+ export async function loadLocalesFromDirectory(directory, options = {}) {
465
510
  const entries = (await fs.readdir(directory, { withFileTypes: true })).sort(
466
511
  (left, right) => left.name.localeCompare(right.name)
467
512
  );
@@ -486,5 +531,8 @@ export async function loadLocalesFromDirectory(directory) {
486
531
  };
487
532
  }
488
533
  }
534
+ for (const [locale, translations] of Object.entries(catalogues)) {
535
+ catalogues[locale] = expandCompatibilityAliases(translations, options);
536
+ }
489
537
  return catalogues;
490
538
  }
package/src/react.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { ReactNode, SelectHTMLAttributes } from 'react';
2
+ import type { I18nInstance, TOptions, TParams } from './index.js';
3
+
4
+ export declare function I18nProvider(props: {
5
+ i18n: I18nInstance;
6
+ children?: ReactNode;
7
+ }): ReactNode;
8
+ export declare function useI18n(): I18nInstance;
9
+ export declare function useLocale(): string;
10
+ export declare function useTranslation(keyPrefix?: string): {
11
+ t(key: string, params?: TParams, options?: TOptions): string;
12
+ i18n: I18nInstance;
13
+ locale: string;
14
+ };
15
+ export declare function Trans(props: {
16
+ id: string;
17
+ values?: Record<string, unknown>;
18
+ options?: TOptions;
19
+ fallback?: string;
20
+ }): ReactNode;
21
+ export declare function LocaleSelector(
22
+ props: SelectHTMLAttributes<HTMLSelectElement> & {
23
+ locales?: string[];
24
+ labels?: Record<string, ReactNode>;
25
+ }
26
+ ): ReactNode;
27
+ export declare function NumberFormat(props: {
28
+ value: number | bigint;
29
+ options?: Intl.NumberFormatOptions;
30
+ }): ReactNode;
31
+ export declare function DateTimeFormat(props: {
32
+ value: Date | number;
33
+ options?: Intl.DateTimeFormatOptions;
34
+ }): ReactNode;
35
+ export declare function RelativeTimeFormat(props: {
36
+ value: number;
37
+ unit: Intl.RelativeTimeFormatUnit;
38
+ options?: Intl.RelativeTimeFormatOptions;
39
+ }): ReactNode;
40
+ export declare function CurrencyFormat(props: {
41
+ value: number | bigint;
42
+ currency: string;
43
+ options?: Intl.NumberFormatOptions;
44
+ }): ReactNode;
package/src/react.js ADDED
@@ -0,0 +1,120 @@
1
+ import React, {
2
+ createContext,
3
+ createElement,
4
+ useCallback,
5
+ useContext,
6
+ useSyncExternalStore,
7
+ } from 'react';
8
+
9
+ const I18nContext = createContext(null);
10
+
11
+ function useI18nContext() {
12
+ const i18n = useContext(I18nContext);
13
+ if (!i18n) {
14
+ throw new Error(
15
+ 'lino-i18n React hooks must be used inside an I18nProvider'
16
+ );
17
+ }
18
+ useSyncExternalStore(i18n.subscribe, i18n.getRevision, i18n.getRevision);
19
+ return i18n;
20
+ }
21
+
22
+ export function I18nProvider({ i18n, children }) {
23
+ if (!i18n?.t || !i18n?.subscribe) {
24
+ throw new TypeError('I18nProvider requires a createI18n instance');
25
+ }
26
+ return createElement(I18nContext.Provider, { value: i18n }, children);
27
+ }
28
+
29
+ export function useI18n() {
30
+ return useI18nContext();
31
+ }
32
+
33
+ export function useLocale() {
34
+ return useI18nContext().getLocale();
35
+ }
36
+
37
+ export function useTranslation(keyPrefix = '') {
38
+ const i18n = useI18nContext();
39
+ const t = useCallback(
40
+ (key, params, options) =>
41
+ i18n.t(keyPrefix ? `${keyPrefix}.${key}` : key, params, options),
42
+ [i18n, keyPrefix]
43
+ );
44
+ return { t, i18n, locale: i18n.getLocale() };
45
+ }
46
+
47
+ export function Trans({ id, values = {}, options, fallback }) {
48
+ const i18n = useI18nContext();
49
+ const entries = Object.entries(values);
50
+ const markers = Object.fromEntries(
51
+ entries.map(([key, value], index) => [
52
+ key,
53
+ React.isValidElement(value) ? `\uE000${index}\uE001` : value,
54
+ ])
55
+ );
56
+ const translated = i18n.t(
57
+ id,
58
+ { ...markers, defaultValue: fallback },
59
+ options
60
+ );
61
+ if (!entries.some(([, value]) => React.isValidElement(value))) {
62
+ return translated;
63
+ }
64
+ const pattern = /(\uE000\d+\uE001)/g;
65
+ return translated.split(pattern).map((part, index) => {
66
+ const match = /^\uE000(\d+)\uE001$/.exec(part);
67
+ return match
68
+ ? React.cloneElement(entries[Number(match[1])][1], { key: index })
69
+ : part;
70
+ });
71
+ }
72
+
73
+ export function LocaleSelector({ locales, labels = {}, ...props }) {
74
+ const i18n = useI18nContext();
75
+ const available = locales || i18n.listLocales();
76
+ return createElement(
77
+ 'select',
78
+ {
79
+ 'aria-label': 'Language',
80
+ ...props,
81
+ value: i18n.getLocale(),
82
+ onChange: (event) => {
83
+ i18n.setLocale(event.target.value);
84
+ props.onChange?.(event);
85
+ },
86
+ },
87
+ available.map((locale) =>
88
+ createElement(
89
+ 'option',
90
+ { key: locale, value: locale },
91
+ labels[locale] || locale
92
+ )
93
+ )
94
+ );
95
+ }
96
+
97
+ function Format({ value, formatter, options }) {
98
+ const locale = useLocale();
99
+ return new Intl[formatter](locale, options).format(value);
100
+ }
101
+
102
+ export function NumberFormat(props) {
103
+ return createElement(Format, { ...props, formatter: 'NumberFormat' });
104
+ }
105
+
106
+ export function DateTimeFormat(props) {
107
+ return createElement(Format, { ...props, formatter: 'DateTimeFormat' });
108
+ }
109
+
110
+ export function RelativeTimeFormat({ value, unit, options }) {
111
+ const locale = useLocale();
112
+ return new Intl.RelativeTimeFormat(locale, options).format(value, unit);
113
+ }
114
+
115
+ export function CurrencyFormat({ value, currency, options }) {
116
+ return createElement(NumberFormat, {
117
+ value,
118
+ options: { style: 'currency', currency, ...options },
119
+ });
120
+ }