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 +27 -0
- package/README.md +98 -8
- package/package.json +18 -2
- package/src/compatibility.js +85 -0
- package/src/format.js +10 -1
- package/src/i18n.js +50 -4
- package/src/index.d.ts +43 -4
- package/src/index.js +1 -0
- package/src/loaders.js +64 -16
- package/src/react.d.ts +44 -0
- package/src/react.js +120 -0
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
58
|
-
`
|
|
59
|
-
|
|
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
|
|
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 = {
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|