lino-i18n 0.0.1
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 +11 -0
- package/LICENSE +24 -0
- package/README.md +109 -0
- package/bin/lino-i18n.js +377 -0
- package/package.json +79 -0
- package/src/converters/detect.js +60 -0
- package/src/converters/i18n-js.js +70 -0
- package/src/converters/i18next.js +98 -0
- package/src/converters/index.js +10 -0
- package/src/converters/react-intl.js +102 -0
- package/src/format.js +115 -0
- package/src/i18n.js +161 -0
- package/src/index.d.ts +122 -0
- package/src/index.js +27 -0
- package/src/loaders.js +490 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Convert i18n-js translation files (Rails-style JSON) into flat lino
|
|
2
|
+
// catalogues. i18n-js uses `%{var}` interpolation; this converter rewrites
|
|
3
|
+
// `%{name}` into `{{name}}` for consistency with the lino-i18n runtime
|
|
4
|
+
// while preserving everything else verbatim.
|
|
5
|
+
|
|
6
|
+
function isPlainObject(value) {
|
|
7
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function rewriteInterpolation(template) {
|
|
11
|
+
if (typeof template !== 'string') {
|
|
12
|
+
return template;
|
|
13
|
+
}
|
|
14
|
+
return template.replace(/%\{\s*([\w.-]+)\s*\}/g, '{{$1}}');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function flatten(prefix, value, out) {
|
|
18
|
+
if (typeof value === 'string') {
|
|
19
|
+
out[prefix] = rewriteInterpolation(value);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
23
|
+
out[prefix] = String(value);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
out[prefix] = value.map(rewriteInterpolation).join('\n');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!isPlainObject(value)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
for (const [key, child] of Object.entries(value)) {
|
|
34
|
+
const nextKey = prefix ? `${prefix}.${key}` : key;
|
|
35
|
+
flatten(nextKey, child, out);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function looksLikeLocale(name) {
|
|
40
|
+
return /^[a-z]{2,3}([-_][A-Za-z0-9]{2,8})*$/i.test(name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function looksMultiLocale(input) {
|
|
44
|
+
if (!isPlainObject(input)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const keys = Object.keys(input);
|
|
48
|
+
if (keys.length === 0) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return keys.every((key) => looksLikeLocale(key) && isPlainObject(input[key]));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function fromI18nJs(input, { locale, defaultLocale = 'en' } = {}) {
|
|
55
|
+
if (!input) {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
if (looksMultiLocale(input)) {
|
|
59
|
+
const result = {};
|
|
60
|
+
for (const [lc, content] of Object.entries(input)) {
|
|
61
|
+
const flat = {};
|
|
62
|
+
flatten('', content, flat);
|
|
63
|
+
result[lc] = flat;
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
const flat = {};
|
|
68
|
+
flatten('', input, flat);
|
|
69
|
+
return { [locale || defaultLocale]: flat };
|
|
70
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Convert i18next resource bundles to flat lino catalogues.
|
|
2
|
+
//
|
|
3
|
+
// Input shapes accepted:
|
|
4
|
+
// 1. A single locale: { greeting: 'Hello', cart: { items: '...' } }
|
|
5
|
+
// 2. Multi-locale: { en: {...}, ru: {...} }
|
|
6
|
+
// 3. Multi-locale with namespaces: { en: { common: {...}, cart: {...} } }
|
|
7
|
+
//
|
|
8
|
+
// Notes:
|
|
9
|
+
// - Nested keys are flattened with `.` (i18next default).
|
|
10
|
+
// - When a namespace is present, keys are prefixed with `ns:`.
|
|
11
|
+
// - i18next-style `_one` / `_other` suffixes are preserved verbatim.
|
|
12
|
+
|
|
13
|
+
function isPlainObject(value) {
|
|
14
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function flatten(prefix, value, out) {
|
|
18
|
+
if (typeof value === 'string') {
|
|
19
|
+
out[prefix] = value;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
23
|
+
out[prefix] = String(value);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
out[prefix] = value.join('\n');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!isPlainObject(value)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
for (const [key, child] of Object.entries(value)) {
|
|
34
|
+
const nextKey = prefix ? `${prefix}.${key}` : key;
|
|
35
|
+
flatten(nextKey, child, out);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function looksLikeLocale(name) {
|
|
40
|
+
return /^[a-z]{2,3}([-_][A-Za-z0-9]{2,8})*$/i.test(name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function looksMultiLocale(input) {
|
|
44
|
+
if (!isPlainObject(input)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const keys = Object.keys(input);
|
|
48
|
+
if (keys.length === 0) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return keys.every((key) => looksLikeLocale(key) && isPlainObject(input[key]));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hasNamespaceShape(localeObj) {
|
|
55
|
+
if (!isPlainObject(localeObj)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const values = Object.values(localeObj);
|
|
59
|
+
if (values.length === 0) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return values.every((value) => isPlainObject(value));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function flattenWithOptionalNamespaces(localeObj) {
|
|
66
|
+
const out = {};
|
|
67
|
+
if (!isPlainObject(localeObj)) {
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
const useNamespaces = hasNamespaceShape(localeObj);
|
|
71
|
+
if (useNamespaces) {
|
|
72
|
+
for (const [ns, content] of Object.entries(localeObj)) {
|
|
73
|
+
const nsOut = {};
|
|
74
|
+
flatten('', content, nsOut);
|
|
75
|
+
for (const [key, value] of Object.entries(nsOut)) {
|
|
76
|
+
out[`${ns}:${key}`] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
flatten('', localeObj, out);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function fromI18next(input, { locale, defaultLocale = 'en' } = {}) {
|
|
86
|
+
if (!input) {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
if (looksMultiLocale(input)) {
|
|
90
|
+
const result = {};
|
|
91
|
+
for (const [lc, content] of Object.entries(input)) {
|
|
92
|
+
result[lc] = flattenWithOptionalNamespaces(content);
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
const chosenLocale = locale || defaultLocale;
|
|
97
|
+
return { [chosenLocale]: flattenWithOptionalNamespaces(input) };
|
|
98
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Converters take an external translation source (i18next JSON, i18n-js
|
|
2
|
+
// JSON, react-intl JSON) and return a normalized `{ [locale]: { [key]: value } }`
|
|
3
|
+
// map ready to be serialised by `formatLinoCatalog`.
|
|
4
|
+
|
|
5
|
+
export { fromI18next } from './i18next.js';
|
|
6
|
+
export { fromI18nJs } from './i18n-js.js';
|
|
7
|
+
export { fromReactIntl } from './react-intl.js';
|
|
8
|
+
export { detectFormat } from './detect.js';
|
|
9
|
+
|
|
10
|
+
export const SUPPORTED_FORMATS = ['i18next', 'i18n-js', 'react-intl'];
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Convert react-intl / FormatJS catalogues into flat lino catalogues.
|
|
2
|
+
//
|
|
3
|
+
// react-intl messages can be in two shapes:
|
|
4
|
+
// 1. Compiled AST: { 'cart.title': [...] }
|
|
5
|
+
// 2. Plain ICU: { 'cart.title': 'Hello, {name}' }
|
|
6
|
+
// or extracted: { 'cart.title': { defaultMessage: 'Hello, {name}' } }
|
|
7
|
+
//
|
|
8
|
+
// This converter:
|
|
9
|
+
// - Always emits ICU `{var}` placeholders verbatim. The lino-i18n
|
|
10
|
+
// interpolation engine accepts both `{var}` and `{{var}}`.
|
|
11
|
+
// - Preserves IDs as keys.
|
|
12
|
+
// - When given an object of `{ defaultMessage, description }`, it
|
|
13
|
+
// takes `defaultMessage`.
|
|
14
|
+
// - Compiled AST messages are decompiled back to ICU when possible.
|
|
15
|
+
|
|
16
|
+
function decompileAst(ast) {
|
|
17
|
+
if (typeof ast === 'string') {
|
|
18
|
+
return ast;
|
|
19
|
+
}
|
|
20
|
+
if (!Array.isArray(ast)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
let out = '';
|
|
24
|
+
for (const node of ast) {
|
|
25
|
+
if (typeof node === 'string') {
|
|
26
|
+
out += node;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (!node || typeof node !== 'object') {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
// FormatJS node shapes: { type: 0, value: 'text' } (literal)
|
|
33
|
+
// { type: 1, value: 'name' } (argument), etc.
|
|
34
|
+
if (node.type === 0 && typeof node.value === 'string') {
|
|
35
|
+
out += node.value;
|
|
36
|
+
} else if (node.type === 1 && typeof node.value === 'string') {
|
|
37
|
+
out += `{${node.value}}`;
|
|
38
|
+
} else if (typeof node.value === 'string') {
|
|
39
|
+
out += `{${node.value}}`;
|
|
40
|
+
} else {
|
|
41
|
+
return null; // cannot represent reliably
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normaliseMessage(entry) {
|
|
48
|
+
if (typeof entry === 'string') {
|
|
49
|
+
return entry;
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(entry)) {
|
|
52
|
+
return decompileAst(entry);
|
|
53
|
+
}
|
|
54
|
+
if (entry && typeof entry === 'object') {
|
|
55
|
+
if (typeof entry.defaultMessage === 'string') {
|
|
56
|
+
return entry.defaultMessage;
|
|
57
|
+
}
|
|
58
|
+
if (typeof entry.message === 'string') {
|
|
59
|
+
return entry.message;
|
|
60
|
+
}
|
|
61
|
+
if (typeof entry.value === 'string') {
|
|
62
|
+
return entry.value;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isLocaleMap(input) {
|
|
69
|
+
if (!input || typeof input !== 'object') {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const keys = Object.keys(input);
|
|
73
|
+
if (keys.length === 0) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return keys.every((key) => /^[a-z]{2,3}([-_][A-Za-z0-9]{2,8})*$/i.test(key));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function fromReactIntl(input, { locale, defaultLocale = 'en' } = {}) {
|
|
80
|
+
if (!input || typeof input !== 'object') {
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
if (isLocaleMap(input)) {
|
|
84
|
+
const result = {};
|
|
85
|
+
for (const [lc, content] of Object.entries(input)) {
|
|
86
|
+
result[lc] = flatten(content);
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
return { [locale || defaultLocale]: flatten(input) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function flatten(messages) {
|
|
94
|
+
const out = {};
|
|
95
|
+
for (const [id, entry] of Object.entries(messages || {})) {
|
|
96
|
+
const value = normaliseMessage(entry);
|
|
97
|
+
if (typeof value === 'string') {
|
|
98
|
+
out[id] = value;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
package/src/format.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Pure helpers used by the i18n runtime: interpolation, plural-suffix
|
|
2
|
+
// resolution against `Intl.PluralRules`, context suffixing, and the key
|
|
3
|
+
// resolution algorithm shared between the runtime and the CLI.
|
|
4
|
+
|
|
5
|
+
const INTERPOLATION_PATTERN = /\{\{?\s*([\w.$:-]+)\s*\}?\}/g;
|
|
6
|
+
|
|
7
|
+
const PLURAL_SUFFIXES = ['zero', 'one', 'two', 'few', 'many', 'other'];
|
|
8
|
+
|
|
9
|
+
export function interpolate(template, params) {
|
|
10
|
+
if (typeof template !== 'string') {
|
|
11
|
+
return template;
|
|
12
|
+
}
|
|
13
|
+
if (!params) {
|
|
14
|
+
return template;
|
|
15
|
+
}
|
|
16
|
+
return template.replace(INTERPOLATION_PATTERN, (match, name) => {
|
|
17
|
+
if (!Object.prototype.hasOwnProperty.call(params, name)) {
|
|
18
|
+
return match;
|
|
19
|
+
}
|
|
20
|
+
const value = params[name];
|
|
21
|
+
return value === null || value === undefined ? '' : String(value);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function pluralSuffix(locale, count) {
|
|
26
|
+
if (count === undefined || count === null) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const numeric = Number(count);
|
|
30
|
+
if (!Number.isFinite(numeric)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const rules = new Intl.PluralRules(locale);
|
|
35
|
+
return rules.select(numeric);
|
|
36
|
+
} catch {
|
|
37
|
+
return numeric === 1 ? 'one' : 'other';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function applyContext(key, context) {
|
|
42
|
+
if (!context) {
|
|
43
|
+
return key;
|
|
44
|
+
}
|
|
45
|
+
return `${key}_${context}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function lookup(table, key) {
|
|
49
|
+
if (!table) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
if (Object.prototype.hasOwnProperty.call(table, key)) {
|
|
53
|
+
return table[key];
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Resolve a key inside a flat translation table that may contain dotted
|
|
59
|
+
// keys (e.g., `cart.items`) or namespaced keys (`navigation:home`).
|
|
60
|
+
// Plural-aware: when `count` is present, the function looks up the
|
|
61
|
+
// best CLDR variant from the given locale and falls back to `_other`.
|
|
62
|
+
//
|
|
63
|
+
// Returns `undefined` when the key cannot be resolved. The caller is
|
|
64
|
+
// responsible for fallback handling.
|
|
65
|
+
export function resolveKey(table, key, { count, context, locale } = {}) {
|
|
66
|
+
if (!table || typeof table !== 'object') {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const withContext = context ? applyContext(key, context) : key;
|
|
70
|
+
|
|
71
|
+
const targets = context ? [withContext, key] : [withContext];
|
|
72
|
+
|
|
73
|
+
for (const target of targets) {
|
|
74
|
+
if (
|
|
75
|
+
count !== undefined &&
|
|
76
|
+
count !== null &&
|
|
77
|
+
Number.isFinite(Number(count))
|
|
78
|
+
) {
|
|
79
|
+
const numeric = Number(count);
|
|
80
|
+
if (numeric === 0) {
|
|
81
|
+
const zeroCandidate = lookup(table, `${target}_zero`);
|
|
82
|
+
if (zeroCandidate !== undefined) {
|
|
83
|
+
return zeroCandidate;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const suffix = pluralSuffix(locale, numeric);
|
|
87
|
+
if (suffix) {
|
|
88
|
+
const candidate = lookup(table, `${target}_${suffix}`);
|
|
89
|
+
if (candidate !== undefined) {
|
|
90
|
+
return candidate;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const otherCandidate = lookup(table, `${target}_other`);
|
|
94
|
+
if (otherCandidate !== undefined) {
|
|
95
|
+
return otherCandidate;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const direct = lookup(table, target);
|
|
100
|
+
if (direct !== undefined) {
|
|
101
|
+
return direct;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (context) {
|
|
106
|
+
const otherFallback = lookup(table, `${key}_other`);
|
|
107
|
+
if (otherFallback !== undefined) {
|
|
108
|
+
return otherFallback;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const _internals = { PLURAL_SUFFIXES, INTERPOLATION_PATTERN };
|
package/src/i18n.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Runtime engine. Returns an i18n instance with `t`, `setLocale`,
|
|
2
|
+
// `getLocale`, `loadLocale`, `loadLocaleFile`, `loadDirectory`,
|
|
3
|
+
// `getFallbacks`, and `has`. The API is small on purpose: combine it with
|
|
4
|
+
// the framework integrations users already have (React, Vue, etc.).
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
loadLocaleFromString,
|
|
8
|
+
loadLocalesFromFile,
|
|
9
|
+
loadLocalesFromDirectory,
|
|
10
|
+
} from './loaders.js';
|
|
11
|
+
import { interpolate, resolveKey } from './format.js';
|
|
12
|
+
|
|
13
|
+
function normalizeFallbacks(fallback) {
|
|
14
|
+
if (!fallback) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(fallback)) {
|
|
18
|
+
return fallback.filter(Boolean).map(String);
|
|
19
|
+
}
|
|
20
|
+
return [String(fallback)];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createI18n(options = {}) {
|
|
24
|
+
const {
|
|
25
|
+
locales = {},
|
|
26
|
+
defaultLocale = 'en',
|
|
27
|
+
fallback = ['en'],
|
|
28
|
+
onMissingKey,
|
|
29
|
+
interpolation = { prefix: '{{', suffix: '}}' },
|
|
30
|
+
} = options;
|
|
31
|
+
|
|
32
|
+
const catalogues = new Map();
|
|
33
|
+
for (const [locale, translations] of Object.entries(locales)) {
|
|
34
|
+
catalogues.set(locale, { ...translations });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let currentLocale = defaultLocale;
|
|
38
|
+
const fallbacks = normalizeFallbacks(fallback);
|
|
39
|
+
|
|
40
|
+
function getLocale() {
|
|
41
|
+
return currentLocale;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function setLocale(locale) {
|
|
45
|
+
if (typeof locale !== 'string' || !locale) {
|
|
46
|
+
throw new TypeError('setLocale expects a non-empty string locale');
|
|
47
|
+
}
|
|
48
|
+
currentLocale = locale;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getFallbacks() {
|
|
52
|
+
return [...fallbacks];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function listLocales() {
|
|
56
|
+
return Array.from(catalogues.keys());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function has(key, locale = currentLocale) {
|
|
60
|
+
const table = catalogues.get(locale);
|
|
61
|
+
return Boolean(table && Object.prototype.hasOwnProperty.call(table, key));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function _lookup(key, opts) {
|
|
65
|
+
const tried = [];
|
|
66
|
+
const locales = [opts?.locale || currentLocale, ...fallbacks];
|
|
67
|
+
for (const locale of locales) {
|
|
68
|
+
if (tried.includes(locale)) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
tried.push(locale);
|
|
72
|
+
const table = catalogues.get(locale);
|
|
73
|
+
if (!table) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const value = resolveKey(table, key, {
|
|
77
|
+
count: opts?.count,
|
|
78
|
+
context: opts?.context,
|
|
79
|
+
locale,
|
|
80
|
+
});
|
|
81
|
+
if (value !== undefined) {
|
|
82
|
+
return { value, locale };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function t(key, params = {}, options = {}) {
|
|
89
|
+
if (typeof key !== 'string') {
|
|
90
|
+
return key;
|
|
91
|
+
}
|
|
92
|
+
const found = _lookup(key, {
|
|
93
|
+
count: params.count,
|
|
94
|
+
context: params.context ?? options.context,
|
|
95
|
+
locale: options.locale,
|
|
96
|
+
});
|
|
97
|
+
if (!found) {
|
|
98
|
+
if (typeof onMissingKey === 'function') {
|
|
99
|
+
const fallbackValue = onMissingKey({ key, params, options });
|
|
100
|
+
if (typeof fallbackValue === 'string') {
|
|
101
|
+
return interpolate(fallbackValue, params);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (params && typeof params.defaultValue === 'string') {
|
|
105
|
+
return interpolate(params.defaultValue, params);
|
|
106
|
+
}
|
|
107
|
+
if (typeof options.defaultValue === 'string') {
|
|
108
|
+
return interpolate(options.defaultValue, params);
|
|
109
|
+
}
|
|
110
|
+
return key;
|
|
111
|
+
}
|
|
112
|
+
return interpolate(found.value, params);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function addLocale(locale, translations) {
|
|
116
|
+
if (!locale || typeof locale !== 'string') {
|
|
117
|
+
throw new TypeError('addLocale requires a string locale name');
|
|
118
|
+
}
|
|
119
|
+
const current = catalogues.get(locale) || {};
|
|
120
|
+
catalogues.set(locale, { ...current, ...translations });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function loadLocale(locale, text) {
|
|
124
|
+
const parsed = await loadLocaleFromString(locale, text);
|
|
125
|
+
if (!parsed.locale) {
|
|
126
|
+
throw new Error('loadLocale could not determine the locale name');
|
|
127
|
+
}
|
|
128
|
+
addLocale(parsed.locale, parsed.translations);
|
|
129
|
+
return parsed.locale;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function loadLocaleFile(filePath) {
|
|
133
|
+
const loaded = await loadLocalesFromFile(filePath);
|
|
134
|
+
for (const { locale, translations } of loaded) {
|
|
135
|
+
addLocale(locale, translations);
|
|
136
|
+
}
|
|
137
|
+
return loaded[0]?.locale;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function loadDirectory(directory) {
|
|
141
|
+
const loaded = await loadLocalesFromDirectory(directory);
|
|
142
|
+
for (const [locale, translations] of Object.entries(loaded)) {
|
|
143
|
+
addLocale(locale, translations);
|
|
144
|
+
}
|
|
145
|
+
return Object.keys(loaded);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
t,
|
|
150
|
+
has,
|
|
151
|
+
getLocale,
|
|
152
|
+
setLocale,
|
|
153
|
+
getFallbacks,
|
|
154
|
+
listLocales,
|
|
155
|
+
addLocale,
|
|
156
|
+
loadLocale,
|
|
157
|
+
loadLocaleFile,
|
|
158
|
+
loadDirectory,
|
|
159
|
+
interpolation,
|
|
160
|
+
};
|
|
161
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Type declarations for the `lino-i18n` package.
|
|
2
|
+
|
|
3
|
+
export interface I18nOptions {
|
|
4
|
+
/** Translation catalogues keyed by locale code. */
|
|
5
|
+
locales?: Record<string, Record<string, string>>;
|
|
6
|
+
/** Locale used when no `setLocale` has been called. */
|
|
7
|
+
defaultLocale?: string;
|
|
8
|
+
/** Fallback locale (or array of locales) used when a key is missing. */
|
|
9
|
+
fallback?: string | string[];
|
|
10
|
+
/** Callback invoked when a key is missing in every locale. */
|
|
11
|
+
onMissingKey?: (info: {
|
|
12
|
+
key: string;
|
|
13
|
+
params: Record<string, unknown>;
|
|
14
|
+
options: TOptions;
|
|
15
|
+
}) => string | void;
|
|
16
|
+
/** Interpolation tokens; currently informational only. */
|
|
17
|
+
interpolation?: { prefix?: string; suffix?: string };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TOptions {
|
|
21
|
+
/** Force a specific locale for this call only. */
|
|
22
|
+
locale?: string;
|
|
23
|
+
/** Gender / context suffix (resolves `key_context`). */
|
|
24
|
+
context?: string;
|
|
25
|
+
/** Default value returned when the key is missing. */
|
|
26
|
+
defaultValue?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TParams extends Record<string, unknown> {
|
|
30
|
+
/** Numeric count used for plural resolution. */
|
|
31
|
+
count?: number;
|
|
32
|
+
/** Context suffix that overrides `TOptions.context`. */
|
|
33
|
+
context?: string;
|
|
34
|
+
/** Default value used when the key is missing. */
|
|
35
|
+
defaultValue?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface I18nInstance {
|
|
39
|
+
t(key: string, params?: TParams, options?: TOptions): string;
|
|
40
|
+
has(key: string, locale?: string): boolean;
|
|
41
|
+
getLocale(): string;
|
|
42
|
+
setLocale(locale: string): void;
|
|
43
|
+
getFallbacks(): string[];
|
|
44
|
+
listLocales(): string[];
|
|
45
|
+
addLocale(locale: string, translations: Record<string, string>): void;
|
|
46
|
+
loadLocale(locale: string, text: string): Promise<string>;
|
|
47
|
+
loadLocaleFile(filePath: string): Promise<string>;
|
|
48
|
+
loadDirectory(directory: string): Promise<string[]>;
|
|
49
|
+
interpolation: { prefix?: string; suffix?: string };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export declare function createI18n(options?: I18nOptions): I18nInstance;
|
|
53
|
+
|
|
54
|
+
export declare function parseLinoCatalog(text: string): {
|
|
55
|
+
locale: string | null;
|
|
56
|
+
translations: Record<string, string>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export declare function parseLinoCatalogs(
|
|
60
|
+
text: string
|
|
61
|
+
): Array<{ locale: string | null; translations: Record<string, string> }>;
|
|
62
|
+
|
|
63
|
+
export declare function formatLinoCatalog(
|
|
64
|
+
locale: string,
|
|
65
|
+
translations: Record<string, string>,
|
|
66
|
+
options?: { style?: 'nested' | 'flat' }
|
|
67
|
+
): string;
|
|
68
|
+
|
|
69
|
+
export declare function formatLinoCatalogs(
|
|
70
|
+
catalogues:
|
|
71
|
+
| Record<string, Record<string, string>>
|
|
72
|
+
| Array<{ locale: string; translations: Record<string, string> }>,
|
|
73
|
+
options?: { style?: 'nested' | 'flat' }
|
|
74
|
+
): string;
|
|
75
|
+
|
|
76
|
+
export declare function loadLocaleFromString(
|
|
77
|
+
locale: string,
|
|
78
|
+
text: string
|
|
79
|
+
): Promise<{ locale: string; translations: Record<string, string> }>;
|
|
80
|
+
|
|
81
|
+
export declare function loadLocaleFromFile(
|
|
82
|
+
filePath: string
|
|
83
|
+
): Promise<{ locale: string; translations: Record<string, string> }>;
|
|
84
|
+
|
|
85
|
+
export declare function loadLocalesFromFile(
|
|
86
|
+
filePath: string
|
|
87
|
+
): Promise<Array<{ locale: string; translations: Record<string, string> }>>;
|
|
88
|
+
|
|
89
|
+
export declare function loadLocalesFromDirectory(
|
|
90
|
+
directory: string
|
|
91
|
+
): Promise<Record<string, Record<string, string>>>;
|
|
92
|
+
|
|
93
|
+
export declare function interpolate(
|
|
94
|
+
template: string,
|
|
95
|
+
params: Record<string, unknown> | undefined
|
|
96
|
+
): string;
|
|
97
|
+
|
|
98
|
+
export declare function pluralSuffix(
|
|
99
|
+
locale: string,
|
|
100
|
+
count: number
|
|
101
|
+
): string | null;
|
|
102
|
+
|
|
103
|
+
export declare function resolveKey(
|
|
104
|
+
table: Record<string, string>,
|
|
105
|
+
key: string,
|
|
106
|
+
options?: { count?: number; context?: string; locale?: string }
|
|
107
|
+
): string | undefined;
|
|
108
|
+
|
|
109
|
+
export declare function fromI18next(
|
|
110
|
+
input: unknown,
|
|
111
|
+
options?: { locale?: string; defaultLocale?: string }
|
|
112
|
+
): Record<string, Record<string, string>>;
|
|
113
|
+
|
|
114
|
+
export declare function fromI18nJs(
|
|
115
|
+
input: unknown,
|
|
116
|
+
options?: { locale?: string; defaultLocale?: string }
|
|
117
|
+
): Record<string, Record<string, string>>;
|
|
118
|
+
|
|
119
|
+
export declare function fromReactIntl(
|
|
120
|
+
input: unknown,
|
|
121
|
+
options?: { locale?: string; defaultLocale?: string }
|
|
122
|
+
): Record<string, Record<string, string>>;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Public API of the `lino-i18n` package.
|
|
2
|
+
//
|
|
3
|
+
// All translations are stored in Links Notation (.lino) files and parsed
|
|
4
|
+
// through `lino-objects-codec`. The runtime exposed here is intentionally
|
|
5
|
+
// small: it supports the features users expect from i18next / i18n-js /
|
|
6
|
+
// react-intl (interpolation, plurals, namespaces, fallbacks, defaults,
|
|
7
|
+
// missing-key callbacks) without locking us to a specific bundler or
|
|
8
|
+
// framework.
|
|
9
|
+
|
|
10
|
+
export { createI18n } from './i18n.js';
|
|
11
|
+
export {
|
|
12
|
+
parseLinoCatalog,
|
|
13
|
+
parseLinoCatalogs,
|
|
14
|
+
formatLinoCatalog,
|
|
15
|
+
formatLinoCatalogs,
|
|
16
|
+
loadLocaleFromString,
|
|
17
|
+
loadLocaleFromFile,
|
|
18
|
+
loadLocalesFromFile,
|
|
19
|
+
loadLocalesFromDirectory,
|
|
20
|
+
} from './loaders.js';
|
|
21
|
+
export {
|
|
22
|
+
interpolate,
|
|
23
|
+
resolveKey,
|
|
24
|
+
pluralSuffix,
|
|
25
|
+
applyContext,
|
|
26
|
+
} from './format.js';
|
|
27
|
+
export { fromI18next, fromI18nJs, fromReactIntl } from './converters/index.js';
|