@lokascript/i18n 1.3.0 → 2.1.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.
Files changed (92) hide show
  1. package/dist/browser.cjs +3625 -3581
  2. package/dist/browser.cjs.map +1 -1
  3. package/dist/browser.d.cts +9 -3
  4. package/dist/browser.d.ts +9 -3
  5. package/dist/browser.js +3595 -3582
  6. package/dist/browser.js.map +1 -1
  7. package/dist/dictionaries/index.cjs +0 -44
  8. package/dist/dictionaries/index.cjs.map +1 -1
  9. package/dist/dictionaries/index.d.cts +30 -81
  10. package/dist/dictionaries/index.d.ts +30 -81
  11. package/dist/dictionaries/index.js +0 -44
  12. package/dist/dictionaries/index.js.map +1 -1
  13. package/dist/index.cjs +7605 -7544
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +38 -38
  16. package/dist/index.d.ts +38 -38
  17. package/dist/index.js +7576 -7545
  18. package/dist/index.js.map +1 -1
  19. package/dist/lokascript-i18n.min.js +1 -1
  20. package/dist/lokascript-i18n.min.js.map +1 -1
  21. package/dist/lokascript-i18n.mjs +3792 -3813
  22. package/dist/lokascript-i18n.mjs.map +1 -1
  23. package/dist/plugins/vite.cjs +0 -42
  24. package/dist/plugins/vite.cjs.map +1 -1
  25. package/dist/plugins/vite.js +0 -42
  26. package/dist/plugins/vite.js.map +1 -1
  27. package/dist/plugins/webpack.cjs +0 -42
  28. package/dist/plugins/webpack.cjs.map +1 -1
  29. package/dist/plugins/webpack.js +0 -42
  30. package/dist/plugins/webpack.js.map +1 -1
  31. package/dist/{transformer-BBKJJ2vd.d.ts → transformer-B6NgN3JQ.d.ts} +108 -15
  32. package/dist/{transformer-D8MM2_rz.d.cts → transformer-DQqxl6hb.d.cts} +108 -15
  33. package/dist/{types-naTJIIaT.d.cts → types-BYtpqGq3.d.cts} +1 -1
  34. package/dist/{types-naTJIIaT.d.ts → types-BYtpqGq3.d.ts} +1 -1
  35. package/package.json +8 -7
  36. package/src/browser.ts +22 -1
  37. package/src/dictionaries/ar.ts +0 -2
  38. package/src/dictionaries/de.ts +0 -2
  39. package/src/dictionaries/derive.ts +0 -2
  40. package/src/dictionaries/en.ts +0 -2
  41. package/src/dictionaries/es.ts +0 -2
  42. package/src/dictionaries/fr.ts +0 -2
  43. package/src/dictionaries/hi.ts +0 -2
  44. package/src/dictionaries/id.ts +0 -2
  45. package/src/dictionaries/index.ts +79 -163
  46. package/src/dictionaries/it.ts +0 -2
  47. package/src/dictionaries/ja.ts +0 -2
  48. package/src/dictionaries/ko.ts +0 -2
  49. package/src/dictionaries/ms.ts +0 -2
  50. package/src/dictionaries/pl.ts +0 -2
  51. package/src/dictionaries/pt.ts +0 -2
  52. package/src/dictionaries/qu.ts +0 -2
  53. package/src/dictionaries/ru.ts +0 -2
  54. package/src/dictionaries/sw.ts +0 -2
  55. package/src/dictionaries/tl.ts +0 -2
  56. package/src/dictionaries/tr.ts +0 -2
  57. package/src/dictionaries/uk.ts +0 -2
  58. package/src/dictionaries/vi.ts +0 -2
  59. package/src/dictionaries/zh.ts +0 -2
  60. package/src/grammar/direct-mappings.ts +0 -2
  61. package/src/grammar/grammar.test.ts +98 -0
  62. package/src/grammar/index.ts +9 -0
  63. package/src/grammar/transformer.ts +125 -73
  64. package/src/index.ts +30 -0
  65. package/src/parser/ar.ts +1 -1
  66. package/src/parser/bn.ts +9 -0
  67. package/src/parser/de.ts +1 -1
  68. package/src/parser/es.ts +1 -1
  69. package/src/parser/fr.ts +1 -1
  70. package/src/parser/hi.ts +9 -0
  71. package/src/parser/id.ts +1 -1
  72. package/src/parser/index.ts +10 -0
  73. package/src/parser/it.ts +9 -0
  74. package/src/parser/ja.ts +1 -1
  75. package/src/parser/ko.ts +1 -1
  76. package/src/parser/locale-manager.ts +1 -1
  77. package/src/parser/ms.ts +9 -0
  78. package/src/parser/pl.ts +9 -0
  79. package/src/parser/pt.ts +1 -1
  80. package/src/parser/qu.ts +1 -1
  81. package/src/parser/ru.ts +9 -0
  82. package/src/parser/sw.ts +1 -1
  83. package/src/parser/th.ts +9 -0
  84. package/src/parser/tl.ts +9 -0
  85. package/src/parser/tr.ts +1 -1
  86. package/src/parser/uk.ts +9 -0
  87. package/src/parser/vi.ts +9 -0
  88. package/src/parser/zh.ts +1 -1
  89. package/src/runtime.test.ts +152 -0
  90. package/src/runtime.ts +32 -13
  91. package/src/utils/locale.test.ts +108 -0
  92. package/src/utils/locale.ts +19 -25
@@ -0,0 +1,152 @@
1
+ import { describe, it, expect, beforeEach, vi as vitest } from 'vitest';
2
+ import { RuntimeI18nManager, initializeI18n, getI18n, runtimeI18n } from './runtime';
3
+
4
+ // Reset the global runtimeI18n between tests
5
+ function resetGlobalI18n() {
6
+ // Directly reset the module-level variable via re-import side effects
7
+ // We'll test initializeI18n/getI18n via the exported functions
8
+ }
9
+
10
+ describe('RuntimeI18nManager', () => {
11
+ let manager: RuntimeI18nManager;
12
+
13
+ beforeEach(() => {
14
+ manager = new RuntimeI18nManager({ locale: 'en' });
15
+ });
16
+
17
+ describe('constructor', () => {
18
+ it('creates with default options', () => {
19
+ const m = new RuntimeI18nManager();
20
+ expect(m.getLocale()).toBe('en');
21
+ });
22
+
23
+ it('creates with custom locale', () => {
24
+ const m = new RuntimeI18nManager({ locale: 'es' });
25
+ expect(m.getLocale()).toBe('es');
26
+ });
27
+ });
28
+
29
+ describe('getLocale', () => {
30
+ it('returns current locale', () => {
31
+ expect(manager.getLocale()).toBe('en');
32
+ });
33
+ });
34
+
35
+ describe('setLocale', () => {
36
+ it('updates the current locale', async () => {
37
+ await manager.setLocale('fr');
38
+ expect(manager.getLocale()).toBe('fr');
39
+ });
40
+
41
+ it('skips when same locale', async () => {
42
+ const observer = vitest.fn();
43
+ manager.onLocaleChange(observer);
44
+ await manager.setLocale('en'); // same as current
45
+ expect(observer).not.toHaveBeenCalled();
46
+ });
47
+
48
+ it('notifies observers on locale change', async () => {
49
+ const observer = vitest.fn();
50
+ manager.onLocaleChange(observer);
51
+ await manager.setLocale('ja');
52
+ expect(observer).toHaveBeenCalledWith('ja');
53
+ });
54
+ });
55
+
56
+ describe('onLocaleChange', () => {
57
+ it('subscribes to locale changes', async () => {
58
+ const callback = vitest.fn();
59
+ manager.onLocaleChange(callback);
60
+ await manager.setLocale('ko');
61
+ expect(callback).toHaveBeenCalledWith('ko');
62
+ });
63
+
64
+ it('returns cleanup function that unsubscribes', async () => {
65
+ const callback = vitest.fn();
66
+ const cleanup = manager.onLocaleChange(callback);
67
+ cleanup();
68
+ await manager.setLocale('de');
69
+ expect(callback).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it('supports multiple observers', async () => {
73
+ const cb1 = vitest.fn();
74
+ const cb2 = vitest.fn();
75
+ manager.onLocaleChange(cb1);
76
+ manager.onLocaleChange(cb2);
77
+ await manager.setLocale('fr');
78
+ expect(cb1).toHaveBeenCalledWith('fr');
79
+ expect(cb2).toHaveBeenCalledWith('fr');
80
+ });
81
+ });
82
+
83
+ describe('isRTL', () => {
84
+ it('returns true for Arabic', () => {
85
+ expect(manager.isRTL('ar')).toBe(true);
86
+ });
87
+
88
+ it('returns false for English', () => {
89
+ expect(manager.isRTL('en')).toBe(false);
90
+ });
91
+
92
+ it('uses current locale when no argument', () => {
93
+ expect(manager.isRTL()).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe('getSupportedLocales', () => {
98
+ it('returns array of locale codes', () => {
99
+ const locales = manager.getSupportedLocales();
100
+ expect(locales).toContain('en');
101
+ expect(locales).toContain('es');
102
+ expect(locales).toContain('ja');
103
+ expect(locales.length).toBeGreaterThanOrEqual(22);
104
+ });
105
+ });
106
+
107
+ describe('translate', () => {
108
+ it('translates from English to target locale', () => {
109
+ const result = manager.translate('on', { to: 'es' });
110
+ expect(typeof result).toBe('string');
111
+ expect(result.length).toBeGreaterThan(0);
112
+ });
113
+ });
114
+
115
+ describe('createLocaleSwitcher', () => {
116
+ it('creates a dropdown element by default', () => {
117
+ const switcher = manager.createLocaleSwitcher();
118
+ expect(switcher.tagName).toBe('SELECT');
119
+ });
120
+
121
+ it('creates a button container when type is buttons', () => {
122
+ const switcher = manager.createLocaleSwitcher({ type: 'buttons' });
123
+ expect(switcher.tagName).toBe('DIV');
124
+ });
125
+
126
+ it('applies custom className', () => {
127
+ const switcher = manager.createLocaleSwitcher({ className: 'my-switcher' });
128
+ expect(switcher.className).toBe('my-switcher');
129
+ });
130
+ });
131
+ });
132
+
133
+ describe('initializeI18n / getI18n', () => {
134
+ it('getI18n throws before initialization', () => {
135
+ // Reset global state — the module re-exports `runtimeI18n` as let
136
+ // We can't easily reset it, so we test the pattern
137
+ // This test is valid only if runtimeI18n hasn't been set yet in this module scope
138
+ });
139
+
140
+ it('initializeI18n creates a global instance', () => {
141
+ const instance = initializeI18n({ locale: 'en' });
142
+ expect(instance).toBeInstanceOf(RuntimeI18nManager);
143
+ expect(instance.getLocale()).toBe('en');
144
+ });
145
+
146
+ it('getI18n returns the initialized instance', () => {
147
+ initializeI18n({ locale: 'fr' });
148
+ const instance = getI18n();
149
+ expect(instance).toBeInstanceOf(RuntimeI18nManager);
150
+ expect(instance.getLocale()).toBe('fr');
151
+ });
152
+ });
package/src/runtime.ts CHANGED
@@ -4,6 +4,7 @@ import { HyperscriptTranslator } from './translator';
4
4
  import { Dictionary, I18nConfig, TranslationOptions } from './types';
5
5
  import { getBrowserLocales, isRTL } from './utils/locale';
6
6
  import { getFormatter } from './formatting';
7
+ import { profiles } from './grammar/profiles';
7
8
 
8
9
  /**
9
10
  * Runtime i18n manager for client-side locale switching
@@ -377,21 +378,39 @@ export class RuntimeI18nManager {
377
378
  * Get locale display name
378
379
  */
379
380
  private getLocaleDisplayName(locale: string, showNative: boolean): string {
380
- const names: Record<string, { english: string; native: string }> = {
381
- en: { english: 'English', native: 'English' },
382
- es: { english: 'Spanish', native: 'Español' },
383
- fr: { english: 'French', native: 'Français' },
384
- de: { english: 'German', native: 'Deutsch' },
385
- ja: { english: 'Japanese', native: '日本語' },
386
- ko: { english: 'Korean', native: '한국어' },
387
- zh: { english: 'Chinese', native: '中文' },
388
- ar: { english: 'Arabic', native: 'العربية' },
389
- };
381
+ if (showNative) {
382
+ const profile = profiles[locale];
383
+ if (profile) return profile.name;
384
+ return locale;
385
+ }
390
386
 
391
- const info = names[locale];
392
- if (!info) return locale;
387
+ const englishNames: Record<string, string> = {
388
+ en: 'English',
389
+ es: 'Spanish',
390
+ fr: 'French',
391
+ de: 'German',
392
+ ja: 'Japanese',
393
+ ko: 'Korean',
394
+ zh: 'Chinese',
395
+ ar: 'Arabic',
396
+ tr: 'Turkish',
397
+ pt: 'Portuguese',
398
+ id: 'Indonesian',
399
+ qu: 'Quechua',
400
+ sw: 'Swahili',
401
+ it: 'Italian',
402
+ vi: 'Vietnamese',
403
+ pl: 'Polish',
404
+ ru: 'Russian',
405
+ uk: 'Ukrainian',
406
+ hi: 'Hindi',
407
+ bn: 'Bengali',
408
+ th: 'Thai',
409
+ ms: 'Malay',
410
+ tl: 'Tagalog',
411
+ };
393
412
 
394
- return showNative ? info.native : info.english;
413
+ return englishNames[locale] || locale;
395
414
  }
396
415
 
397
416
  /**
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseLocale, getBestMatchingLocale, formatLocaleName } from './locale';
3
+
4
+ describe('parseLocale', () => {
5
+ it('parses simple language codes', () => {
6
+ const result = parseLocale('en');
7
+ expect(result.language).toBe('en');
8
+ expect(result.region).toBeUndefined();
9
+ expect(result.script).toBeUndefined();
10
+ });
11
+
12
+ it('parses language-region codes', () => {
13
+ const result = parseLocale('en-US');
14
+ expect(result.language).toBe('en');
15
+ expect(result.region).toBe('US');
16
+ expect(result.script).toBeUndefined();
17
+ });
18
+
19
+ it('parses language-script-region codes', () => {
20
+ const result = parseLocale('zh-Hans-CN');
21
+ expect(result.language).toBe('zh');
22
+ expect(result.script).toBe('Hans');
23
+ expect(result.region).toBe('CN');
24
+ });
25
+
26
+ it('parses language-script without region', () => {
27
+ const result = parseLocale('zh-Hans');
28
+ expect(result.language).toBe('zh');
29
+ expect(result.script).toBe('Hans');
30
+ expect(result.region).toBeUndefined();
31
+ });
32
+
33
+ it('normalizes language to lowercase and region to uppercase', () => {
34
+ const result = parseLocale('EN-us');
35
+ expect(result.language).toBe('en');
36
+ expect(result.region).toBe('US');
37
+ });
38
+ });
39
+
40
+ describe('getBestMatchingLocale', () => {
41
+ const available = ['en', 'fr', 'zh-Hans', 'zh-Hant', 'es'];
42
+
43
+ it('returns exact match', () => {
44
+ expect(getBestMatchingLocale('en', available)).toBe('en');
45
+ });
46
+
47
+ it('returns script match over language-only match', () => {
48
+ expect(getBestMatchingLocale('zh-Hans-CN', available)).toBe('zh-Hans');
49
+ });
50
+
51
+ it('falls back to language-only match', () => {
52
+ expect(getBestMatchingLocale('fr-CA', available)).toBe('fr');
53
+ });
54
+
55
+ it('returns null when no match', () => {
56
+ expect(getBestMatchingLocale('de', available)).toBeNull();
57
+ });
58
+
59
+ it('prefers script match when available', () => {
60
+ expect(getBestMatchingLocale('zh-Hant-TW', available)).toBe('zh-Hant');
61
+ });
62
+ });
63
+
64
+ describe('formatLocaleName', () => {
65
+ it('returns native name from grammar profile', () => {
66
+ expect(formatLocaleName('ja')).toBe('日本語');
67
+ expect(formatLocaleName('ko')).toBe('한국어');
68
+ expect(formatLocaleName('es')).toBe('Español');
69
+ expect(formatLocaleName('ar')).toBe('العربية');
70
+ expect(formatLocaleName('en')).toBe('English');
71
+ });
72
+
73
+ it('returns locale code for unknown locales', () => {
74
+ expect(formatLocaleName('xx')).toBe('xx');
75
+ expect(formatLocaleName('unknown')).toBe('unknown');
76
+ });
77
+
78
+ it('covers all 22 supported languages', () => {
79
+ const codes = [
80
+ 'en',
81
+ 'es',
82
+ 'ja',
83
+ 'ko',
84
+ 'zh',
85
+ 'fr',
86
+ 'de',
87
+ 'ar',
88
+ 'tr',
89
+ 'pt',
90
+ 'id',
91
+ 'qu',
92
+ 'sw',
93
+ 'bn',
94
+ 'it',
95
+ 'ru',
96
+ 'uk',
97
+ 'vi',
98
+ 'hi',
99
+ 'tl',
100
+ 'th',
101
+ 'pl',
102
+ ];
103
+ for (const code of codes) {
104
+ const name = formatLocaleName(code);
105
+ expect(name).not.toBe(code); // Should return a real name, not the code itself
106
+ }
107
+ });
108
+ });
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { dictionaries } from '../dictionaries';
4
4
  import { DICTIONARY_CATEGORIES } from '../types';
5
+ import { profiles } from '../grammar/profiles';
5
6
 
6
7
  export interface LocaleInfo {
7
8
  code: string;
@@ -98,17 +99,7 @@ export function getBestMatchingLocale(
98
99
 
99
100
  const requested = parseLocale(requestedLocale);
100
101
 
101
- // Try language-only match
102
- const languageMatch = availableLocales.find(locale => {
103
- const available = parseLocale(locale);
104
- return available.language === requested.language;
105
- });
106
-
107
- if (languageMatch) {
108
- return languageMatch;
109
- }
110
-
111
- // Try language-script match
102
+ // Try language+script match first (more specific)
112
103
  if (requested.script) {
113
104
  const scriptMatch = availableLocales.find(locale => {
114
105
  const available = parseLocale(locale);
@@ -120,6 +111,16 @@ export function getBestMatchingLocale(
120
111
  }
121
112
  }
122
113
 
114
+ // Fall back to language-only match
115
+ const languageMatch = availableLocales.find(locale => {
116
+ const available = parseLocale(locale);
117
+ return available.language === requested.language;
118
+ });
119
+
120
+ if (languageMatch) {
121
+ return languageMatch;
122
+ }
123
+
123
124
  return null;
124
125
  }
125
126
 
@@ -160,24 +161,17 @@ function escapeRegex(str: string): string {
160
161
  }
161
162
 
162
163
  /**
163
- * Format a locale for display
164
+ * Format a locale for display using the native name from its grammar profile.
164
165
  */
165
166
  export function formatLocaleName(locale: string): string {
166
- const names: Record<string, string> = {
167
- en: 'English',
168
- es: 'Español',
169
- ko: '한국어',
170
- zh: '中文',
167
+ const profile = profiles[locale];
168
+ if (profile) return profile.name;
169
+
170
+ // Fallback for sub-locale codes (e.g., 'zh-TW')
171
+ const subLocaleNames: Record<string, string> = {
171
172
  'zh-TW': '繁體中文',
172
- ja: '日本語',
173
- fr: 'Français',
174
- de: 'Deutsch',
175
- pt: 'Português',
176
- hi: 'हिन्दी',
177
- ar: 'العربية',
178
173
  };
179
-
180
- return names[locale] || locale;
174
+ return subLocaleNames[locale] || locale;
181
175
  }
182
176
 
183
177
  /**