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