lino-i18n 0.0.1 → 0.1.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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # lino-i18n Changelog
2
2
 
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 22cf52e: Document the Hive Mind deep catalogue authoring pattern and keep JS examples aligned.
8
+
9
+ ## 0.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 4c3b133: Add configurable compatibility aliases for deeper nested migration keys.
14
+
15
+ ## 0.0.2
16
+
17
+ ### Patch Changes
18
+
19
+ - 29f0d6f: Preserve scalar parent translations as `label` children when formatting nested
20
+ catalogues, and resolve `foo` from `foo.label` when no explicit `foo`
21
+ translation exists.
22
+
3
23
  ## 0.0.1
4
24
 
5
25
  Initial release of the JavaScript `lino-i18n` package.
package/README.md CHANGED
@@ -30,6 +30,7 @@ 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
 
35
36
  A sample `.lino` catalogue looks like this:
@@ -37,11 +38,26 @@ A sample `.lino` catalogue looks like this:
37
38
  ```lino
38
39
  en
39
40
  greeting "Hello, {{name}}!"
40
- hero
41
- description """
42
- Keep each language in its own block, nest related messages together,
43
- and still resolve the same runtime keys.
44
- """
41
+ telegram
42
+ help
43
+ title "Help"
44
+ solve
45
+ alias
46
+ detail "Tool aliases imply `--tool <tool>`"
47
+ prompt
48
+ system
49
+ general
50
+ guidelines
51
+ header "General guidelines."
52
+ body """
53
+ When you start, create a detailed plan for yourself.
54
+ Follow your todo list step by step.
55
+ """
56
+ error
57
+ label "Error"
58
+ invalid
59
+ github
60
+ url "Error: Invalid GitHub URL format"
45
61
  cart
46
62
  title "Your cart"
47
63
  items
@@ -54,9 +70,36 @@ en
54
70
  other "They are a developer"
55
71
  ```
56
72
 
57
- Nested plural and context groups flatten to the runtime suffix keys
58
- `cart.items_one`, `cart.items_other`, and `role_female`. A single file may also
59
- contain several top-level locale blocks, for example `en` followed by `ru`.
73
+ Deeply nested blocks flatten to canonical dot keys such as
74
+ `telegram.help.solve.alias.detail` and
75
+ `prompt.system.general.guidelines.body`. Nested plural and context groups still
76
+ flatten to runtime suffix keys such as `cart.items_one`, `cart.items_other`,
77
+ and `role_female`. A single file may also contain several top-level locale
78
+ blocks, for example `en` followed by `ru`.
79
+
80
+ Use a `label` child when a translated group also needs its own runtime key:
81
+ `error.label` and `error` both resolve to `"Error"`, and an explicit `error`
82
+ translation wins over the generated alias.
83
+
84
+ For migrations from flatter catalogues, enable compatibility aliases when
85
+ loading or creating the runtime:
86
+
87
+ ```js
88
+ const catalogues = await loadLocalesFromDirectory('./locales', {
89
+ compatibilityAliases: ['collapseTail', 'parentLabel'],
90
+ });
91
+ const i18n = createI18n({
92
+ locales: catalogues,
93
+ defaultLocale: 'en',
94
+ });
95
+ ```
96
+
97
+ `collapseTail` exposes underscore-tail aliases for deeper keys, so
98
+ `telegram.help.solve.alias.detail` also resolves through
99
+ `telegram.help_solve_alias_detail`, `telegram.help.solve_alias_detail`, and
100
+ `telegram.help.solve.alias_detail`. `parentLabel` maps `error.label` to the
101
+ legacy parent key `error`. Generated aliases never overwrite explicit
102
+ translations.
60
103
 
61
104
  ## CLI
62
105
 
@@ -90,7 +133,9 @@ Run `npx lino-i18n --help` for every option.
90
133
  - `{{var}}` and `{var}` placeholder syntax for compatibility with i18next
91
134
  and `react-intl`.
92
135
  - Context (gender) suffixes: `role_male`, `role_female`, `role_other`.
136
+ - Migration aliases for deeper nested keys and parent labels.
93
137
  - Namespace prefixes via `:` (`navigation:home`) and `.` (`cart.title`).
138
+ - Group label aliases via `label` children.
94
139
  - Configurable fallback chain.
95
140
  - Bundled multi-locale `.lino` files and per-language directories.
96
141
  - Optional missing-key handler.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lino-i18n",
3
- "version": "0.0.1",
3
+ "version": "0.1.1",
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",
@@ -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 = { PLURAL_SUFFIXES, INTERPOLATION_PATTERN };
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,11 +31,18 @@ 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(locale, { ...translations });
42
+ catalogues.set(
43
+ locale,
44
+ expandCompatibilityAliases(translations, { compatibilityAliases })
45
+ );
35
46
  }
36
47
 
37
48
  let currentLocale = defaultLocale;
@@ -58,7 +69,7 @@ export function createI18n(options = {}) {
58
69
 
59
70
  function has(key, locale = currentLocale) {
60
71
  const table = catalogues.get(locale);
61
- return Boolean(table && Object.prototype.hasOwnProperty.call(table, key));
72
+ return resolveKey(table, key, { locale }) !== undefined;
62
73
  }
63
74
 
64
75
  function _lookup(key, opts) {
@@ -117,7 +128,13 @@ export function createI18n(options = {}) {
117
128
  throw new TypeError('addLocale requires a string locale name');
118
129
  }
119
130
  const current = catalogues.get(locale) || {};
120
- catalogues.set(locale, { ...current, ...translations });
131
+ catalogues.set(
132
+ locale,
133
+ expandCompatibilityAliases(
134
+ { ...current, ...translations },
135
+ { compatibilityAliases }
136
+ )
137
+ );
121
138
  }
122
139
 
123
140
  async function loadLocale(locale, text) {
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 {
@@ -51,15 +66,33 @@ export interface I18nInstance {
51
66
 
52
67
  export declare function createI18n(options?: I18nOptions): I18nInstance;
53
68
 
69
+ export declare function expandCompatibilityAliases(
70
+ translations: Record<string, string>,
71
+ options?: CompatibilityAliasOptions
72
+ ): Record<string, string>;
73
+
54
74
  export declare function parseLinoCatalog(text: string): {
55
75
  locale: string | null;
56
76
  translations: Record<string, string>;
57
77
  };
58
78
 
79
+ export declare function parseLinoCatalog(
80
+ text: string,
81
+ options: CompatibilityAliasOptions
82
+ ): {
83
+ locale: string | null;
84
+ translations: Record<string, string>;
85
+ };
86
+
59
87
  export declare function parseLinoCatalogs(
60
88
  text: string
61
89
  ): Array<{ locale: string | null; translations: Record<string, string> }>;
62
90
 
91
+ export declare function parseLinoCatalogs(
92
+ text: string,
93
+ options: CompatibilityAliasOptions
94
+ ): Array<{ locale: string | null; translations: Record<string, string> }>;
95
+
63
96
  export declare function formatLinoCatalog(
64
97
  locale: string,
65
98
  translations: Record<string, string>,
@@ -75,19 +108,23 @@ export declare function formatLinoCatalogs(
75
108
 
76
109
  export declare function loadLocaleFromString(
77
110
  locale: string,
78
- text: string
111
+ text: string,
112
+ options?: CompatibilityAliasOptions
79
113
  ): Promise<{ locale: string; translations: Record<string, string> }>;
80
114
 
81
115
  export declare function loadLocaleFromFile(
82
- filePath: string
116
+ filePath: string,
117
+ options?: CompatibilityAliasOptions
83
118
  ): Promise<{ locale: string; translations: Record<string, string> }>;
84
119
 
85
120
  export declare function loadLocalesFromFile(
86
- filePath: string
121
+ filePath: string,
122
+ options?: CompatibilityAliasOptions
87
123
  ): Promise<Array<{ locale: string; translations: Record<string, string> }>>;
88
124
 
89
125
  export declare function loadLocalesFromDirectory(
90
- directory: string
126
+ directory: string,
127
+ options?: CompatibilityAliasOptions
91
128
  ): Promise<Record<string, Record<string, string>>>;
92
129
 
93
130
  export declare function interpolate(
package/src/index.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // framework.
9
9
 
10
10
  export { createI18n } from './i18n.js';
11
+ export { expandCompatibilityAliases } from './compatibility.js';
11
12
  export {
12
13
  parseLinoCatalog,
13
14
  parseLinoCatalogs,
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
- const base = nextPath.join('.');
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
- if (!isPlainObject(node[part])) {
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
- node[parts[parts.length - 1]] = value;
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
- for (const [key, value] of Object.entries(tree)) {
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
  }