lino-i18n 0.2.0 → 0.3.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 +6 -0
- package/README.md +61 -0
- package/package.json +12 -2
- package/src/browser.d.ts +57 -0
- package/src/browser.js +57 -0
- package/src/catalogs.js +482 -0
- package/src/i18n.js +2 -28
- package/src/index.d.ts +6 -3
- package/src/index.js +1 -1
- package/src/language.js +46 -0
- package/src/loaders.js +9 -481
- package/src/node-i18n.js +25 -0
- package/src/react.d.ts +14 -5
package/src/catalogs.js
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
// Loaders translate `.lino` text or files into the flat translation
|
|
2
|
+
// tables consumed by the runtime. The authoring format is an i18n-focused
|
|
3
|
+
// subset of indented Links Notation: each top-level block is a locale, nested
|
|
4
|
+
// blocks become dotted keys, and plural/context selector blocks become
|
|
5
|
+
// underscore suffixes such as `cart.items_one` and `role_female`.
|
|
6
|
+
|
|
7
|
+
import { expandCompatibilityAliases } from './compatibility.js';
|
|
8
|
+
|
|
9
|
+
const SELECTOR_SUFFIXES = new Set([
|
|
10
|
+
'zero',
|
|
11
|
+
'one',
|
|
12
|
+
'two',
|
|
13
|
+
'few',
|
|
14
|
+
'many',
|
|
15
|
+
'other',
|
|
16
|
+
'male',
|
|
17
|
+
'female',
|
|
18
|
+
'neutral',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const LABEL_ALIAS_KEY = 'label';
|
|
22
|
+
|
|
23
|
+
function unescapeValue(value, quote = '"') {
|
|
24
|
+
let result = '';
|
|
25
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
26
|
+
const char = value[index];
|
|
27
|
+
if (char !== '\\') {
|
|
28
|
+
result += char;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
index += 1;
|
|
32
|
+
const next = value[index];
|
|
33
|
+
if (next === undefined || next === '\\') {
|
|
34
|
+
result += '\\';
|
|
35
|
+
} else if (next === 'n') {
|
|
36
|
+
result += '\n';
|
|
37
|
+
} else if (next === 'r') {
|
|
38
|
+
result += '\r';
|
|
39
|
+
} else if (next === 't') {
|
|
40
|
+
result += '\t';
|
|
41
|
+
} else if (next === quote) {
|
|
42
|
+
result += quote;
|
|
43
|
+
} else {
|
|
44
|
+
result += `\\${next}`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function escapeValue(value) {
|
|
51
|
+
return value
|
|
52
|
+
.split('\\')
|
|
53
|
+
.join('\\\\')
|
|
54
|
+
.split('"')
|
|
55
|
+
.join('\\"')
|
|
56
|
+
.split('\r')
|
|
57
|
+
.join('\\r')
|
|
58
|
+
.split('\t')
|
|
59
|
+
.join('\\t');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function countIndent(line) {
|
|
63
|
+
let count = 0;
|
|
64
|
+
for (const char of line) {
|
|
65
|
+
if (char === ' ') {
|
|
66
|
+
count += 1;
|
|
67
|
+
} else if (char === '\t') {
|
|
68
|
+
count += 2;
|
|
69
|
+
} else {
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return count;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function stripContentIndent(line, indent) {
|
|
77
|
+
let remaining = indent;
|
|
78
|
+
let index = 0;
|
|
79
|
+
while (index < line.length && remaining > 0) {
|
|
80
|
+
const char = line[index];
|
|
81
|
+
if (char === ' ') {
|
|
82
|
+
remaining -= 1;
|
|
83
|
+
index += 1;
|
|
84
|
+
} else if (char === '\t') {
|
|
85
|
+
remaining -= 2;
|
|
86
|
+
index += 1;
|
|
87
|
+
} else {
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return line.slice(index);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function findClosingQuote(value, quote, start = 1) {
|
|
95
|
+
let escaped = false;
|
|
96
|
+
for (let index = start; index < value.length; index += 1) {
|
|
97
|
+
const char = value[index];
|
|
98
|
+
if (escaped) {
|
|
99
|
+
escaped = false;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (char === '\\') {
|
|
103
|
+
escaped = true;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (char === quote) {
|
|
107
|
+
return index;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return -1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseTripleQuotedValue(lines, index, rest, indent) {
|
|
114
|
+
const sameLine = rest.slice(3);
|
|
115
|
+
if (sameLine.trim()) {
|
|
116
|
+
const closing = sameLine.lastIndexOf('"""');
|
|
117
|
+
if (closing !== -1) {
|
|
118
|
+
return {
|
|
119
|
+
value: sameLine.slice(0, closing),
|
|
120
|
+
nextIndex: index + 1,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const valueLines = [];
|
|
126
|
+
const contentIndent = indent + 2;
|
|
127
|
+
for (let current = index + 1; current < lines.length; current += 1) {
|
|
128
|
+
const raw = lines[current].replace(/\r$/, '');
|
|
129
|
+
if (raw.trim() === '"""') {
|
|
130
|
+
return {
|
|
131
|
+
value: valueLines.join('\n'),
|
|
132
|
+
nextIndex: current + 1,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
valueLines.push(stripContentIndent(raw, contentIndent));
|
|
136
|
+
}
|
|
137
|
+
throw new Error('unterminated multiline string');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseQuotedValue(lines, index, rest, indent) {
|
|
141
|
+
if (rest.startsWith('"""')) {
|
|
142
|
+
return parseTripleQuotedValue(lines, index, rest, indent);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const quote = rest[0];
|
|
146
|
+
const closing = findClosingQuote(rest, quote);
|
|
147
|
+
if (closing !== -1) {
|
|
148
|
+
return {
|
|
149
|
+
value: unescapeValue(rest.slice(1, closing), quote),
|
|
150
|
+
nextIndex: index + 1,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const chunks = [rest.slice(1)];
|
|
155
|
+
for (let current = index + 1; current < lines.length; current += 1) {
|
|
156
|
+
const raw = lines[current].replace(/\r$/, '');
|
|
157
|
+
const lineClosing = findClosingQuote(raw, quote, 0);
|
|
158
|
+
if (lineClosing !== -1) {
|
|
159
|
+
chunks.push(raw.slice(0, lineClosing));
|
|
160
|
+
return {
|
|
161
|
+
value: unescapeValue(chunks.join('\n'), quote),
|
|
162
|
+
nextIndex: current + 1,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
chunks.push(raw);
|
|
166
|
+
}
|
|
167
|
+
throw new Error('unterminated quoted string');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseLogicalLines(text) {
|
|
171
|
+
const lines = String(text || '')
|
|
172
|
+
.replace(/\r\n/g, '\n')
|
|
173
|
+
.split('\n');
|
|
174
|
+
const entries = [];
|
|
175
|
+
for (let index = 0; index < lines.length; ) {
|
|
176
|
+
const raw = lines[index].replace(/\r$/, '');
|
|
177
|
+
const content = raw.trimEnd();
|
|
178
|
+
if (!content.trim() || content.trimStart().startsWith('#')) {
|
|
179
|
+
index += 1;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const indent = countIndent(content);
|
|
184
|
+
const trimmed = content.trimStart();
|
|
185
|
+
const match = /^(\S+)(?:\s+(.*))?$/.exec(trimmed);
|
|
186
|
+
if (!match) {
|
|
187
|
+
index += 1;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let key = match[1];
|
|
192
|
+
const rest = match[2]?.trimStart();
|
|
193
|
+
if (rest === undefined && key.endsWith(':')) {
|
|
194
|
+
key = key.slice(0, -1);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (rest === undefined) {
|
|
198
|
+
entries.push({ indent, key, value: null });
|
|
199
|
+
index += 1;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (rest.startsWith('"') || rest.startsWith("'")) {
|
|
204
|
+
const parsed = parseQuotedValue(lines, index, rest, indent);
|
|
205
|
+
entries.push({ indent, key, value: parsed.value });
|
|
206
|
+
index = parsed.nextIndex;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
entries.push({ indent, key, value: unescapeValue(rest.trim()) });
|
|
211
|
+
index += 1;
|
|
212
|
+
}
|
|
213
|
+
return entries;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function parseEntriesAt(lines, start, indent) {
|
|
217
|
+
const tree = {};
|
|
218
|
+
let index = start;
|
|
219
|
+
|
|
220
|
+
while (index < lines.length) {
|
|
221
|
+
const line = lines[index];
|
|
222
|
+
if (line.indent < indent) {
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
if (line.indent > indent) {
|
|
226
|
+
throw new Error(`unexpected indentation before ${line.key}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
index += 1;
|
|
230
|
+
if (line.value !== null) {
|
|
231
|
+
tree[line.key] = line.value;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (index < lines.length && lines[index].indent > line.indent) {
|
|
236
|
+
const parsed = parseEntriesAt(lines, index, lines[index].indent);
|
|
237
|
+
tree[line.key] = parsed.tree;
|
|
238
|
+
index = parsed.index;
|
|
239
|
+
} else {
|
|
240
|
+
tree[line.key] = {};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { tree, index };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function parseLocaleTrees(text) {
|
|
248
|
+
const lines = parseLogicalLines(text);
|
|
249
|
+
const catalogues = [];
|
|
250
|
+
let index = 0;
|
|
251
|
+
|
|
252
|
+
while (index < lines.length) {
|
|
253
|
+
const root = lines[index];
|
|
254
|
+
if (root.indent !== 0) {
|
|
255
|
+
throw new Error(`expected a locale root before ${root.key}`);
|
|
256
|
+
}
|
|
257
|
+
if (root.value !== null) {
|
|
258
|
+
throw new Error(`locale root ${root.key} cannot have a direct value`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
index += 1;
|
|
262
|
+
let tree = {};
|
|
263
|
+
if (index < lines.length && lines[index].indent > root.indent) {
|
|
264
|
+
const parsed = parseEntriesAt(lines, index, lines[index].indent);
|
|
265
|
+
tree = parsed.tree;
|
|
266
|
+
index = parsed.index;
|
|
267
|
+
}
|
|
268
|
+
catalogues.push({ locale: root.key, tree });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return catalogues;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isPlainObject(value) {
|
|
275
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function isSelectorGroup(value) {
|
|
279
|
+
const entries = Object.entries(value).filter(
|
|
280
|
+
([key]) => key !== LABEL_ALIAS_KEY
|
|
281
|
+
);
|
|
282
|
+
return (
|
|
283
|
+
entries.length > 0 &&
|
|
284
|
+
entries.every(
|
|
285
|
+
([key, child]) => SELECTOR_SUFFIXES.has(key) && typeof child === 'string'
|
|
286
|
+
)
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function labelAliasValue(value) {
|
|
291
|
+
return typeof value[LABEL_ALIAS_KEY] === 'string'
|
|
292
|
+
? value[LABEL_ALIAS_KEY]
|
|
293
|
+
: undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function addLabelAlias(out, base, value) {
|
|
297
|
+
if (!Object.prototype.hasOwnProperty.call(out, base)) {
|
|
298
|
+
out[base] = value;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function flattenTree(tree, pathParts = [], out = {}) {
|
|
303
|
+
for (const [key, value] of Object.entries(tree)) {
|
|
304
|
+
if (typeof value === 'string') {
|
|
305
|
+
out[[...pathParts, key].join('.')] = value;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (!isPlainObject(value)) {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const nextPath = [...pathParts, key];
|
|
313
|
+
const base = nextPath.join('.');
|
|
314
|
+
const labelValue = labelAliasValue(value);
|
|
315
|
+
if (isSelectorGroup(value)) {
|
|
316
|
+
if (labelValue !== undefined) {
|
|
317
|
+
out[`${base}.${LABEL_ALIAS_KEY}`] = labelValue;
|
|
318
|
+
addLabelAlias(out, base, labelValue);
|
|
319
|
+
}
|
|
320
|
+
for (const [suffix, child] of Object.entries(value)) {
|
|
321
|
+
if (suffix === LABEL_ALIAS_KEY) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
out[`${base}_${suffix}`] = child;
|
|
325
|
+
}
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
flattenTree(value, nextPath, out);
|
|
329
|
+
if (labelValue !== undefined) {
|
|
330
|
+
addLabelAlias(out, base, labelValue);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function splitSelectorSuffix(key) {
|
|
337
|
+
const index = key.lastIndexOf('_');
|
|
338
|
+
if (index <= 0) {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
const suffix = key.slice(index + 1);
|
|
342
|
+
if (!SELECTOR_SUFFIXES.has(suffix)) {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
return { base: key.slice(0, index), suffix };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function setNestedValue(tree, parts, value) {
|
|
349
|
+
let node = tree;
|
|
350
|
+
for (const part of parts.slice(0, -1)) {
|
|
351
|
+
const current = node[part];
|
|
352
|
+
if (!isPlainObject(current)) {
|
|
353
|
+
node[part] = {};
|
|
354
|
+
if (typeof current === 'string') {
|
|
355
|
+
node[part][LABEL_ALIAS_KEY] = current;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
node = node[part];
|
|
359
|
+
}
|
|
360
|
+
const leaf = parts[parts.length - 1];
|
|
361
|
+
if (isPlainObject(node[leaf])) {
|
|
362
|
+
if (!Object.prototype.hasOwnProperty.call(node[leaf], LABEL_ALIAS_KEY)) {
|
|
363
|
+
node[leaf][LABEL_ALIAS_KEY] = value;
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
node[leaf] = value;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function translationsToTree(translations) {
|
|
371
|
+
const tree = {};
|
|
372
|
+
for (const [rawKey, rawValue] of Object.entries(translations || {})) {
|
|
373
|
+
const key = String(rawKey);
|
|
374
|
+
const value = typeof rawValue === 'string' ? rawValue : String(rawValue);
|
|
375
|
+
const selector = splitSelectorSuffix(key);
|
|
376
|
+
if (selector) {
|
|
377
|
+
setNestedValue(
|
|
378
|
+
tree,
|
|
379
|
+
[...selector.base.split('.'), selector.suffix],
|
|
380
|
+
value
|
|
381
|
+
);
|
|
382
|
+
} else {
|
|
383
|
+
setNestedValue(tree, key.split('.'), value);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return tree;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function formatValue(value, indent) {
|
|
390
|
+
if (value.includes('\n')) {
|
|
391
|
+
const contentIndent = `${indent} `;
|
|
392
|
+
const lines = value.split('\n').map((line) => `${contentIndent}${line}`);
|
|
393
|
+
return `"""\n${lines.join('\n')}\n${indent}"""`;
|
|
394
|
+
}
|
|
395
|
+
return `"${escapeValue(value)}"`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function formatTreeLines(tree, indent = ' ') {
|
|
399
|
+
const lines = [];
|
|
400
|
+
const entries = Object.entries(tree);
|
|
401
|
+
const labelEntry = entries.find(([key]) => key === LABEL_ALIAS_KEY);
|
|
402
|
+
const orderedEntries = labelEntry
|
|
403
|
+
? [labelEntry, ...entries.filter(([key]) => key !== LABEL_ALIAS_KEY)]
|
|
404
|
+
: entries;
|
|
405
|
+
for (const [key, value] of orderedEntries) {
|
|
406
|
+
if (typeof value === 'string') {
|
|
407
|
+
lines.push(`${indent}${key} ${formatValue(value, indent)}`);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (!isPlainObject(value)) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
lines.push(`${indent}${key}`);
|
|
414
|
+
lines.push(...formatTreeLines(value, `${indent} `));
|
|
415
|
+
}
|
|
416
|
+
return lines;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function formatFlatCatalog(locale, translations) {
|
|
420
|
+
const lines = [String(locale)];
|
|
421
|
+
for (const [key, value] of Object.entries(translations || {})) {
|
|
422
|
+
const safe = typeof value === 'string' ? value : String(value);
|
|
423
|
+
lines.push(` ${key} ${formatValue(safe, ' ')}`);
|
|
424
|
+
}
|
|
425
|
+
return lines.join('\n');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Parse the contents of one `.lino` catalogue. Returns the first
|
|
429
|
+
// `{ locale, translations }` pair when the file contains multiple locale
|
|
430
|
+
// roots. Use `parseLinoCatalogs` to keep every root.
|
|
431
|
+
export function parseLinoCatalog(text, options = {}) {
|
|
432
|
+
const catalogues = parseLinoCatalogs(text, options);
|
|
433
|
+
return catalogues[0] || { locale: null, translations: {} };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Parse every top-level locale block in a `.lino` string.
|
|
437
|
+
export function parseLinoCatalogs(text, options = {}) {
|
|
438
|
+
return parseLocaleTrees(text).map(({ locale, tree }) => ({
|
|
439
|
+
locale: locale || null,
|
|
440
|
+
translations: expandCompatibilityAliases(flattenTree(tree), options),
|
|
441
|
+
}));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Format a flat translation table as indented Links Notation. The default
|
|
445
|
+
// output is nested for readability; pass `{ style: 'flat' }` to emit one
|
|
446
|
+
// key/value line per translation.
|
|
447
|
+
export function formatLinoCatalog(locale, translations, options = {}) {
|
|
448
|
+
if (!locale) {
|
|
449
|
+
throw new Error('formatLinoCatalog requires a locale name');
|
|
450
|
+
}
|
|
451
|
+
if (options.style === 'flat') {
|
|
452
|
+
return formatFlatCatalog(locale, translations);
|
|
453
|
+
}
|
|
454
|
+
const lines = [String(locale)];
|
|
455
|
+
lines.push(...formatTreeLines(translationsToTree(translations)));
|
|
456
|
+
return lines.join('\n');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function formatLinoCatalogs(catalogues, options = {}) {
|
|
460
|
+
const entries = Array.isArray(catalogues)
|
|
461
|
+
? catalogues.map(({ locale, translations }) => [locale, translations])
|
|
462
|
+
: Object.entries(catalogues || {}).sort(([left], [right]) =>
|
|
463
|
+
left.localeCompare(right)
|
|
464
|
+
);
|
|
465
|
+
return entries
|
|
466
|
+
.map(([locale, translations]) =>
|
|
467
|
+
formatLinoCatalog(locale, translations, options)
|
|
468
|
+
)
|
|
469
|
+
.join('\n\n');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export async function loadLocaleFromString(locale, text, options = {}) {
|
|
473
|
+
const parsedCatalogues = parseLinoCatalogs(text, options);
|
|
474
|
+
const parsed = parsedCatalogues.find(
|
|
475
|
+
(catalogue) => catalogue.locale === locale
|
|
476
|
+
) ||
|
|
477
|
+
parsedCatalogues[0] || { locale: null, translations: {} };
|
|
478
|
+
return {
|
|
479
|
+
locale: locale || parsed.locale,
|
|
480
|
+
translations: parsed.translations,
|
|
481
|
+
};
|
|
482
|
+
}
|
package/src/i18n.js
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
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';
|
|
1
|
+
// Shared translation engine. Platform-specific loading stays in adapters.
|
|
2
|
+
import { loadLocaleFromString } from './catalogs.js';
|
|
11
3
|
import {
|
|
12
4
|
expandCompatibilityAliases,
|
|
13
5
|
normalizeCompatibilityAliases,
|
|
@@ -173,22 +165,6 @@ export function createI18n(options = {}) {
|
|
|
173
165
|
return parsed.locale;
|
|
174
166
|
}
|
|
175
167
|
|
|
176
|
-
async function loadLocaleFile(filePath) {
|
|
177
|
-
const loaded = await loadLocalesFromFile(filePath);
|
|
178
|
-
for (const { locale, translations } of loaded) {
|
|
179
|
-
addLocale(locale, translations);
|
|
180
|
-
}
|
|
181
|
-
return loaded[0]?.locale;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
async function loadDirectory(directory) {
|
|
185
|
-
const loaded = await loadLocalesFromDirectory(directory);
|
|
186
|
-
for (const [locale, translations] of Object.entries(loaded)) {
|
|
187
|
-
addLocale(locale, translations);
|
|
188
|
-
}
|
|
189
|
-
return Object.keys(loaded);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
168
|
return {
|
|
193
169
|
t,
|
|
194
170
|
has,
|
|
@@ -198,8 +174,6 @@ export function createI18n(options = {}) {
|
|
|
198
174
|
listLocales,
|
|
199
175
|
addLocale,
|
|
200
176
|
loadLocale,
|
|
201
|
-
loadLocaleFile,
|
|
202
|
-
loadDirectory,
|
|
203
177
|
interpolation,
|
|
204
178
|
subscribe,
|
|
205
179
|
getRevision,
|
package/src/index.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export interface TParams extends Record<string, unknown> {
|
|
|
50
50
|
defaultValue?: string;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export interface
|
|
53
|
+
export interface I18nCoreInstance {
|
|
54
54
|
t(key: string, params?: TParams, options?: TOptions): string;
|
|
55
55
|
has(key: string, locale?: string): boolean;
|
|
56
56
|
getLocale(): string;
|
|
@@ -59,13 +59,16 @@ export interface I18nInstance {
|
|
|
59
59
|
listLocales(): string[];
|
|
60
60
|
addLocale(locale: string, translations: Record<string, string>): void;
|
|
61
61
|
loadLocale(locale: string, text: string): Promise<string>;
|
|
62
|
-
loadLocaleFile(filePath: string): Promise<string>;
|
|
63
|
-
loadDirectory(directory: string): Promise<string[]>;
|
|
64
62
|
interpolation: { prefix?: string; suffix?: string };
|
|
65
63
|
subscribe(listener: () => void): () => void;
|
|
66
64
|
getRevision(): number;
|
|
67
65
|
}
|
|
68
66
|
|
|
67
|
+
export interface I18nInstance extends I18nCoreInstance {
|
|
68
|
+
loadLocaleFile(filePath: string): Promise<string>;
|
|
69
|
+
loadDirectory(directory: string): Promise<string[]>;
|
|
70
|
+
}
|
|
71
|
+
|
|
69
72
|
export declare function createI18n(options?: I18nOptions): I18nInstance;
|
|
70
73
|
|
|
71
74
|
export declare function expandCompatibilityAliases(
|
package/src/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// missing-key callbacks) without locking us to a specific bundler or
|
|
8
8
|
// framework.
|
|
9
9
|
|
|
10
|
-
export { createI18n } from './i18n.js';
|
|
10
|
+
export { createI18n } from './node-i18n.js';
|
|
11
11
|
export { expandCompatibilityAliases } from './compatibility.js';
|
|
12
12
|
export {
|
|
13
13
|
parseLinoCatalog,
|
package/src/language.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
function normalizeTag(value) {
|
|
2
|
+
return String(value || '')
|
|
3
|
+
.trim()
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
.replaceAll('_', '-');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function matchLanguage(value, supported) {
|
|
9
|
+
let tag = normalizeTag(value);
|
|
10
|
+
if (tag === 'auto') {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
while (tag) {
|
|
14
|
+
if (supported.has(tag)) {
|
|
15
|
+
return supported.get(tag);
|
|
16
|
+
}
|
|
17
|
+
const separator = tag.lastIndexOf('-');
|
|
18
|
+
tag = separator === -1 ? '' : tag.slice(0, separator);
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Return the catalogue's original locale spelling after comparing normalized
|
|
24
|
+
// tags. Unsupported preferences defer to candidates in their supplied order.
|
|
25
|
+
export function resolveLanguage(preference, candidates = [], options = {}) {
|
|
26
|
+
const { supportedLanguages = ['en'], defaultLocale = 'en' } = options;
|
|
27
|
+
const supported = new Map(
|
|
28
|
+
supportedLanguages.map((locale) => [normalizeTag(locale), locale])
|
|
29
|
+
);
|
|
30
|
+
const languages = Array.isArray(candidates) ? candidates : [candidates];
|
|
31
|
+
for (const candidate of [preference, ...languages, defaultLocale]) {
|
|
32
|
+
const matched = matchLanguage(candidate, supported);
|
|
33
|
+
if (matched) {
|
|
34
|
+
return matched;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return supportedLanguages[0] || defaultLocale;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function detectLanguage(preference = 'auto', options = {}) {
|
|
41
|
+
const navigator = globalThis.navigator;
|
|
42
|
+
const candidates = navigator?.languages?.length
|
|
43
|
+
? Array.from(navigator.languages)
|
|
44
|
+
: [navigator?.language];
|
|
45
|
+
return resolveLanguage(preference, candidates, options);
|
|
46
|
+
}
|