@lokascript/i18n 1.0.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 (96) hide show
  1. package/README.md +286 -0
  2. package/dist/browser.cjs +7669 -0
  3. package/dist/browser.cjs.map +1 -0
  4. package/dist/browser.d.cts +50 -0
  5. package/dist/browser.d.ts +50 -0
  6. package/dist/browser.js +7592 -0
  7. package/dist/browser.js.map +1 -0
  8. package/dist/hyperfixi-i18n.min.js +2 -0
  9. package/dist/hyperfixi-i18n.min.js.map +1 -0
  10. package/dist/hyperfixi-i18n.mjs +8558 -0
  11. package/dist/hyperfixi-i18n.mjs.map +1 -0
  12. package/dist/index.cjs +14205 -0
  13. package/dist/index.cjs.map +1 -0
  14. package/dist/index.d.cts +947 -0
  15. package/dist/index.d.ts +947 -0
  16. package/dist/index.js +14095 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/transformer-Ckask-yw.d.cts +1041 -0
  19. package/dist/transformer-Ckask-yw.d.ts +1041 -0
  20. package/package.json +84 -0
  21. package/src/browser.ts +122 -0
  22. package/src/compatibility/browser-tests/grammar-demo.spec.ts +169 -0
  23. package/src/constants.ts +366 -0
  24. package/src/dictionaries/ar.ts +233 -0
  25. package/src/dictionaries/bn.ts +156 -0
  26. package/src/dictionaries/de.ts +233 -0
  27. package/src/dictionaries/derive.ts +515 -0
  28. package/src/dictionaries/en.ts +237 -0
  29. package/src/dictionaries/es.ts +233 -0
  30. package/src/dictionaries/fr.ts +233 -0
  31. package/src/dictionaries/hi.ts +270 -0
  32. package/src/dictionaries/id.ts +233 -0
  33. package/src/dictionaries/index.ts +238 -0
  34. package/src/dictionaries/it.ts +233 -0
  35. package/src/dictionaries/ja.ts +233 -0
  36. package/src/dictionaries/ko.ts +233 -0
  37. package/src/dictionaries/ms.ts +276 -0
  38. package/src/dictionaries/pl.ts +239 -0
  39. package/src/dictionaries/pt.ts +237 -0
  40. package/src/dictionaries/qu.ts +233 -0
  41. package/src/dictionaries/ru.ts +270 -0
  42. package/src/dictionaries/sw.ts +233 -0
  43. package/src/dictionaries/th.ts +156 -0
  44. package/src/dictionaries/tl.ts +276 -0
  45. package/src/dictionaries/tr.ts +233 -0
  46. package/src/dictionaries/uk.ts +270 -0
  47. package/src/dictionaries/vi.ts +210 -0
  48. package/src/dictionaries/zh.ts +233 -0
  49. package/src/enhanced-i18n.test.ts +454 -0
  50. package/src/enhanced-i18n.ts +713 -0
  51. package/src/examples/new-languages.ts +326 -0
  52. package/src/formatting.test.ts +213 -0
  53. package/src/formatting.ts +416 -0
  54. package/src/grammar/direct-mappings.ts +353 -0
  55. package/src/grammar/grammar.test.ts +1053 -0
  56. package/src/grammar/index.ts +59 -0
  57. package/src/grammar/profiles/index.ts +860 -0
  58. package/src/grammar/transformer.ts +1318 -0
  59. package/src/grammar/types.ts +630 -0
  60. package/src/index.ts +202 -0
  61. package/src/new-languages.test.ts +389 -0
  62. package/src/parser/analyze-conflicts.test.ts +229 -0
  63. package/src/parser/ar.ts +40 -0
  64. package/src/parser/create-provider.ts +309 -0
  65. package/src/parser/de.ts +36 -0
  66. package/src/parser/es.ts +31 -0
  67. package/src/parser/fr.ts +31 -0
  68. package/src/parser/id.ts +34 -0
  69. package/src/parser/index.ts +50 -0
  70. package/src/parser/ja.ts +36 -0
  71. package/src/parser/ko.ts +37 -0
  72. package/src/parser/locale-manager.test.ts +198 -0
  73. package/src/parser/locale-manager.ts +197 -0
  74. package/src/parser/parser-integration.test.ts +439 -0
  75. package/src/parser/pt.ts +37 -0
  76. package/src/parser/qu.ts +37 -0
  77. package/src/parser/sw.ts +37 -0
  78. package/src/parser/tr.ts +38 -0
  79. package/src/parser/types.ts +113 -0
  80. package/src/parser/zh.ts +38 -0
  81. package/src/plugins/vite.ts +224 -0
  82. package/src/plugins/webpack.ts +124 -0
  83. package/src/pluralization.test.ts +197 -0
  84. package/src/pluralization.ts +393 -0
  85. package/src/runtime.ts +441 -0
  86. package/src/ssr-integration.ts +225 -0
  87. package/src/test-setup.ts +195 -0
  88. package/src/translation-validation.test.ts +171 -0
  89. package/src/translator.test.ts +252 -0
  90. package/src/translator.ts +297 -0
  91. package/src/types.ts +209 -0
  92. package/src/utils/locale.ts +190 -0
  93. package/src/utils/tokenizer-adapter.ts +469 -0
  94. package/src/utils/tokenizer.ts +19 -0
  95. package/src/validators/index.ts +174 -0
  96. package/src/validators/schema.ts +129 -0
@@ -0,0 +1,416 @@
1
+ // packages/i18n/src/formatting.ts
2
+
3
+ /**
4
+ * Locale-aware formatting utilities for i18n
5
+ */
6
+
7
+ export interface NumberFormatOptions {
8
+ style?: 'decimal' | 'currency' | 'percent';
9
+ currency?: string;
10
+ minimumFractionDigits?: number;
11
+ maximumFractionDigits?: number;
12
+ useGrouping?: boolean;
13
+ }
14
+
15
+ export interface DateFormatOptions {
16
+ dateStyle?: 'full' | 'long' | 'medium' | 'short';
17
+ timeStyle?: 'full' | 'long' | 'medium' | 'short';
18
+ year?: 'numeric' | '2-digit';
19
+ month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow';
20
+ day?: 'numeric' | '2-digit';
21
+ hour?: 'numeric' | '2-digit';
22
+ minute?: 'numeric' | '2-digit';
23
+ second?: 'numeric' | '2-digit';
24
+ timeZone?: string;
25
+ }
26
+
27
+ export interface RelativeTimeFormatOptions {
28
+ style?: 'long' | 'short' | 'narrow';
29
+ numeric?: 'always' | 'auto';
30
+ }
31
+
32
+ /**
33
+ * Format numbers according to locale
34
+ */
35
+ export class NumberFormatter {
36
+ private locale: string;
37
+ private defaultOptions: NumberFormatOptions;
38
+
39
+ constructor(locale: string, defaultOptions: NumberFormatOptions = {}) {
40
+ this.locale = locale;
41
+ this.defaultOptions = defaultOptions;
42
+ }
43
+
44
+ format(value: number, options: NumberFormatOptions = {}): string {
45
+ const mergedOptions = { ...this.defaultOptions, ...options };
46
+
47
+ try {
48
+ const result = new Intl.NumberFormat(this.locale, mergedOptions).format(value);
49
+ // Validate result - if style is currency but no currency symbol, use fallback
50
+ if (mergedOptions.style === 'currency' && !/[$€£¥₹₩₽฿₪₴₱₫₵₦]/.test(result)) {
51
+ return this.fallbackFormat(value, mergedOptions);
52
+ }
53
+ // Validate result - if style is percent but no % symbol, use fallback
54
+ if (mergedOptions.style === 'percent' && !result.includes('%')) {
55
+ return this.fallbackFormat(value, mergedOptions);
56
+ }
57
+ // Validate result - if input has decimals but output lost them (broken Intl like jsdom)
58
+ // Check by seeing if the result represents a rounded integer when it shouldn't
59
+ const hasFractionalPart = value % 1 !== 0;
60
+ if (hasFractionalPart && mergedOptions.style !== 'percent') {
61
+ // Extract numeric value from result (remove thousand separators, keep decimal)
62
+ // If the result only contains digits and thousand separators (no decimal), it's broken
63
+ const digitsOnly = result.replace(/[^\d.-]/g, '');
64
+ const parsedValue = parseFloat(digitsOnly);
65
+ // If parsed value is an integer but input had decimals, Intl rounded incorrectly
66
+ if (!isNaN(parsedValue) && parsedValue % 1 === 0) {
67
+ return this.fallbackFormat(value, mergedOptions);
68
+ }
69
+ }
70
+ return result;
71
+ } catch (error) {
72
+ // Fallback for unsupported locales
73
+ return this.fallbackFormat(value, mergedOptions);
74
+ }
75
+ }
76
+
77
+ formatCurrency(
78
+ value: number,
79
+ currency: string,
80
+ options: Omit<NumberFormatOptions, 'style' | 'currency'> = {}
81
+ ): string {
82
+ return this.format(value, {
83
+ ...options,
84
+ style: 'currency',
85
+ currency,
86
+ });
87
+ }
88
+
89
+ formatPercent(value: number, options: Omit<NumberFormatOptions, 'style'> = {}): string {
90
+ // Note: Intl.NumberFormat with style: 'percent' multiplies by 100
91
+ // So 0.25 becomes "25%"
92
+ return this.format(value, {
93
+ ...options,
94
+ style: 'percent',
95
+ });
96
+ }
97
+
98
+ private fallbackFormat(value: number, options: NumberFormatOptions): string {
99
+ const { style, currency, useGrouping = true } = options;
100
+ // Default fraction digits based on style
101
+ const defaultMin = style === 'currency' ? 2 : 0;
102
+ const defaultMax = style === 'currency' ? 2 : 3;
103
+ const minimumFractionDigits = options.minimumFractionDigits ?? defaultMin;
104
+ const maximumFractionDigits = options.maximumFractionDigits ?? defaultMax;
105
+
106
+ let formatted = value.toFixed(
107
+ Math.min(maximumFractionDigits, Math.max(minimumFractionDigits, 0))
108
+ );
109
+
110
+ // Remove trailing zeros after decimal point if not required
111
+ if (minimumFractionDigits === 0 && formatted.includes('.')) {
112
+ formatted = formatted.replace(/\.?0+$/, '');
113
+ }
114
+
115
+ // Add thousand separators if grouping is enabled
116
+ if (useGrouping !== false) {
117
+ const parts = formatted.split('.');
118
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
119
+ formatted = parts.join('.');
120
+ }
121
+
122
+ if (style === 'currency' && currency) {
123
+ // Map currency codes to symbols
124
+ const currencySymbols: Record<string, string> = {
125
+ USD: '$',
126
+ EUR: '€',
127
+ GBP: '£',
128
+ JPY: '¥',
129
+ CNY: '¥',
130
+ KRW: '₩',
131
+ RUB: '₽',
132
+ INR: '₹',
133
+ THB: '฿',
134
+ ILS: '₪',
135
+ UAH: '₴',
136
+ PHP: '₱',
137
+ VND: '₫',
138
+ GHS: '₵',
139
+ NGN: '₦',
140
+ };
141
+ const symbol = currencySymbols[currency] || currency;
142
+ return `${symbol}${formatted}`;
143
+ }
144
+
145
+ if (style === 'percent') {
146
+ // Multiply by 100 for percent display (matching Intl behavior)
147
+ const percentValue = (value * 100).toFixed(
148
+ Math.min(maximumFractionDigits, Math.max(minimumFractionDigits, 0))
149
+ );
150
+ const cleanPercent =
151
+ minimumFractionDigits === 0 && percentValue.includes('.')
152
+ ? percentValue.replace(/\.?0+$/, '')
153
+ : percentValue;
154
+ return `${cleanPercent}%`;
155
+ }
156
+
157
+ return formatted;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Format dates according to locale
163
+ */
164
+ export class DateFormatter {
165
+ private locale: string;
166
+ private defaultOptions: DateFormatOptions;
167
+
168
+ constructor(locale: string, defaultOptions: DateFormatOptions = {}) {
169
+ this.locale = locale;
170
+ this.defaultOptions = defaultOptions;
171
+ }
172
+
173
+ format(date: Date | string | number, options: DateFormatOptions = {}): string {
174
+ const dateObj = new Date(date);
175
+ const mergedOptions = { ...this.defaultOptions, ...options };
176
+
177
+ try {
178
+ const result = new Intl.DateTimeFormat(this.locale, mergedOptions).format(dateObj);
179
+ // Validate: if timeStyle was requested but no time in result, use fallback
180
+ if (mergedOptions.timeStyle && !/\d{1,2}:\d{2}/.test(result)) {
181
+ return this.fallbackFormat(dateObj, mergedOptions);
182
+ }
183
+ return result;
184
+ } catch (error) {
185
+ return this.fallbackFormat(dateObj, mergedOptions);
186
+ }
187
+ }
188
+
189
+ formatRelative(date: Date | string | number, options: RelativeTimeFormatOptions = {}): string {
190
+ const dateObj = new Date(date);
191
+ const now = new Date();
192
+ const diffMs = dateObj.getTime() - now.getTime();
193
+
194
+ try {
195
+ const rtf = new Intl.RelativeTimeFormat(this.locale, options);
196
+
197
+ const diffSeconds = Math.round(diffMs / 1000);
198
+ const diffMinutes = Math.round(diffMs / (1000 * 60));
199
+ const diffHours = Math.round(diffMs / (1000 * 60 * 60));
200
+ const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
201
+
202
+ if (Math.abs(diffSeconds) < 60) {
203
+ return rtf.format(diffSeconds, 'second');
204
+ } else if (Math.abs(diffMinutes) < 60) {
205
+ return rtf.format(diffMinutes, 'minute');
206
+ } else if (Math.abs(diffHours) < 24) {
207
+ return rtf.format(diffHours, 'hour');
208
+ } else if (Math.abs(diffDays) < 30) {
209
+ return rtf.format(diffDays, 'day');
210
+ } else {
211
+ return this.format(dateObj);
212
+ }
213
+ } catch (error) {
214
+ return this.fallbackRelativeFormat(dateObj, now);
215
+ }
216
+ }
217
+
218
+ private fallbackFormat(date: Date, options: DateFormatOptions): string {
219
+ // Basic fallback formatting
220
+ const year = date.getFullYear();
221
+ const month = String(date.getMonth() + 1).padStart(2, '0');
222
+ const day = String(date.getDate()).padStart(2, '0');
223
+ const hour = String(date.getHours()).padStart(2, '0');
224
+ const minute = String(date.getMinutes()).padStart(2, '0');
225
+
226
+ const dateStr = `${month}/${day}/${year}`;
227
+ const timeStr = `${hour}:${minute}`;
228
+
229
+ // Include time if timeStyle is set
230
+ if (options.timeStyle) {
231
+ return options.dateStyle ? `${dateStr} ${timeStr}` : timeStr;
232
+ }
233
+
234
+ if (options.dateStyle || (!options.dateStyle && !options.timeStyle)) {
235
+ return dateStr;
236
+ }
237
+
238
+ return date.toString();
239
+ }
240
+
241
+ private fallbackRelativeFormat(date: Date, now: Date): string {
242
+ const diffMs = date.getTime() - now.getTime();
243
+ const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
244
+
245
+ if (diffDays === 0) return 'today';
246
+ if (diffDays === 1) return 'tomorrow';
247
+ if (diffDays === -1) return 'yesterday';
248
+ if (diffDays > 0) return `in ${diffDays} days`;
249
+ return `${Math.abs(diffDays)} days ago`;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Locale-aware formatting manager
255
+ */
256
+ export class LocaleFormatter {
257
+ private locale: string;
258
+ private numberFormatter: NumberFormatter;
259
+ private dateFormatter: DateFormatter;
260
+
261
+ constructor(locale: string) {
262
+ this.locale = locale;
263
+ this.numberFormatter = new NumberFormatter(locale);
264
+ this.dateFormatter = new DateFormatter(locale);
265
+ }
266
+
267
+ // Number formatting
268
+ formatNumber(value: number, options?: NumberFormatOptions): string {
269
+ return this.numberFormatter.format(value, options);
270
+ }
271
+
272
+ formatCurrency(
273
+ value: number,
274
+ currency: string,
275
+ options?: Omit<NumberFormatOptions, 'style' | 'currency'>
276
+ ): string {
277
+ return this.numberFormatter.formatCurrency(value, currency, options);
278
+ }
279
+
280
+ formatPercent(value: number, options?: Omit<NumberFormatOptions, 'style'>): string {
281
+ return this.numberFormatter.formatPercent(value, options);
282
+ }
283
+
284
+ // Date formatting
285
+ formatDate(date: Date | string | number, options?: DateFormatOptions): string {
286
+ return this.dateFormatter.format(date, options);
287
+ }
288
+
289
+ formatRelativeTime(date: Date | string | number, options?: RelativeTimeFormatOptions): string {
290
+ return this.dateFormatter.formatRelative(date, options);
291
+ }
292
+
293
+ // Specialized hyperscript formatting
294
+ formatHyperscriptValue(value: any, type?: string): string {
295
+ if (typeof value === 'number') {
296
+ if (type === 'currency') {
297
+ return this.formatCurrency(value, 'USD');
298
+ }
299
+ if (type === 'percent') {
300
+ return this.formatPercent(value);
301
+ }
302
+ return this.formatNumber(value);
303
+ }
304
+
305
+ if (value instanceof Date || (typeof value === 'string' && !isNaN(Date.parse(value)))) {
306
+ if (type === 'relative') {
307
+ return this.formatRelativeTime(value);
308
+ }
309
+ return this.formatDate(value);
310
+ }
311
+
312
+ return String(value);
313
+ }
314
+
315
+ // List formatting
316
+ formatList(
317
+ items: string[],
318
+ options: {
319
+ style?: 'long' | 'short' | 'narrow';
320
+ type?: 'conjunction' | 'disjunction' | 'unit';
321
+ } = {}
322
+ ): string {
323
+ if (items.length === 0) return '';
324
+ if (items.length === 1) return items[0];
325
+
326
+ try {
327
+ // ListFormat may not be available in all environments
328
+ const ListFormatCtor = (Intl as any).ListFormat;
329
+ if (ListFormatCtor) {
330
+ const listFormat = new ListFormatCtor(this.locale, options);
331
+ return listFormat.format(items);
332
+ }
333
+ throw new Error('ListFormat not available');
334
+ } catch (_error) {
335
+ // Fallback for unsupported locales
336
+ const { type = 'conjunction' } = options;
337
+ const connector = type === 'disjunction' ? 'or' : 'and';
338
+
339
+ if (items.length === 2) {
340
+ return `${items[0]} ${connector} ${items[1]}`;
341
+ }
342
+
343
+ return `${items.slice(0, -1).join(', ')}, ${connector} ${items[items.length - 1]}`;
344
+ }
345
+ }
346
+
347
+ // Unit formatting
348
+ formatUnit(
349
+ value: number,
350
+ unit: string,
351
+ options: { style?: 'long' | 'short' | 'narrow' } = {}
352
+ ): string {
353
+ try {
354
+ // Map common hyperscript units to Intl units
355
+ const unitMap: Record<string, string> = {
356
+ second: 'second',
357
+ seconds: 'second',
358
+ minute: 'minute',
359
+ minutes: 'minute',
360
+ hour: 'hour',
361
+ hours: 'hour',
362
+ day: 'day',
363
+ days: 'day',
364
+ pixel: 'pixel',
365
+ pixels: 'pixel',
366
+ px: 'pixel',
367
+ percent: 'percent',
368
+ '%': 'percent',
369
+ };
370
+
371
+ const intlUnit = unitMap[unit.toLowerCase()] || unit;
372
+
373
+ if (Intl.NumberFormat.prototype.constructor.name === 'NumberFormat') {
374
+ // Check if environment supports unit formatting
375
+ const testFormatter = new Intl.NumberFormat(this.locale, {
376
+ style: 'unit',
377
+ unit: intlUnit,
378
+ unitDisplay: options.style || 'long',
379
+ } as any);
380
+
381
+ return testFormatter.format(value);
382
+ }
383
+ } catch (error) {
384
+ // Fallback formatting
385
+ }
386
+
387
+ return `${value} ${unit}`;
388
+ }
389
+
390
+ // Change locale
391
+ setLocale(locale: string): void {
392
+ this.locale = locale;
393
+ this.numberFormatter = new NumberFormatter(locale);
394
+ this.dateFormatter = new DateFormatter(locale);
395
+ }
396
+
397
+ getLocale(): string {
398
+ return this.locale;
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Global formatting utilities
404
+ */
405
+ export const formatters = new Map<string, LocaleFormatter>();
406
+
407
+ export function getFormatter(locale: string): LocaleFormatter {
408
+ if (!formatters.has(locale)) {
409
+ formatters.set(locale, new LocaleFormatter(locale));
410
+ }
411
+ return formatters.get(locale)!;
412
+ }
413
+
414
+ export function formatForLocale(locale: string, value: any, type?: string): string {
415
+ return getFormatter(locale).formatHyperscriptValue(value, type);
416
+ }