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.
@@ -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
- // 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';
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 I18nInstance {
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,
@@ -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
+ }