lokal-react 1.3.2 → 1.3.6

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.
@@ -1,18 +1,102 @@
1
1
  import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
2
2
  import type { LocaleData } from 'lokal-core';
3
3
 
4
+ // ============================================
5
+ // Type Definitions
6
+ // ============================================
7
+
4
8
  // Type for translation function
5
9
  export type TranslateFunction = <K extends string>(
6
10
  key: K,
7
11
  params?: Record<string, string | number>
8
12
  ) => string;
9
13
 
14
+ // Pluralization types
15
+ export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
16
+
17
+ export interface PluralParams {
18
+ count: number;
19
+ [key: string]: string | number;
20
+ }
21
+
22
+ // Gender types
23
+ export type GenderCategory = 'male' | 'female' | 'other';
24
+
25
+ export interface GenderParams {
26
+ gender: GenderCategory;
27
+ [key: string]: string | number;
28
+ }
29
+
30
+ // Extended translation function with pluralization and gender
31
+ export interface ExtendedTranslateFunction {
32
+ // Basic translation
33
+ <K extends string>(key: K, params?: Record<string, string | number>): string;
34
+
35
+ // Pluralization - use key like "items_plural" with {count}
36
+ plural: <K extends string>(key: K, params: PluralParams) => string;
37
+
38
+ // Gender - use key like "user_gender" with {gender}
39
+ gender: <K extends string>(key: K, params: GenderParams) => string;
40
+
41
+ // Choice/select - use key like "color_choice" with {value}
42
+ choice: <K extends string>(key: K, params: { value: string | number }) => string;
43
+ }
44
+
10
45
  // Storage interface for different platforms
11
46
  export interface StorageInterface {
12
47
  getItem(key: string): Promise<string | null>;
13
48
  setItem(key: string, value: string): Promise<void>;
14
49
  }
15
50
 
51
+ // Date formatting options
52
+ export interface DateFormatOptions {
53
+ year?: 'numeric' | '2-digit';
54
+ month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow';
55
+ day?: 'numeric' | '2-digit';
56
+ hour?: 'numeric' | '2-digit';
57
+ minute?: 'numeric' | '2-digit';
58
+ second?: 'numeric' | '2-digit';
59
+ timeZoneName?: 'short' | 'long';
60
+ }
61
+
62
+ // Number formatting options
63
+ export interface NumberFormatOptions {
64
+ style?: 'decimal' | 'currency' | 'percent' | 'unit';
65
+ currency?: string;
66
+ currencyDisplay?: 'symbol' | 'code' | 'name';
67
+ minimumFractionDigits?: number;
68
+ maximumFractionDigits?: number;
69
+ useGrouping?: boolean;
70
+ unit?: string;
71
+ unitDisplay?: 'long' | 'short' | 'narrow';
72
+ }
73
+
74
+ // ============================================
75
+ // Context Value
76
+ // ============================================
77
+
78
+ export interface LokalContextValue {
79
+ locale: string;
80
+ setLocale: (locale: string) => void;
81
+ locales: string[];
82
+ t: ExtendedTranslateFunction;
83
+ translations: LocaleData;
84
+ isLoading: boolean;
85
+ // Date/Number formatting
86
+ formatDate: (date: Date | string, options?: DateFormatOptions) => string;
87
+ formatNumber: (num: number, options?: NumberFormatOptions) => string;
88
+ }
89
+
90
+ // ============================================
91
+ // Context Creation
92
+ // ============================================
93
+
94
+ const LokalContext = createContext<LokalContextValue | null>(null);
95
+
96
+ // ============================================
97
+ // Storage Interface
98
+ // ============================================
99
+
16
100
  // Default to localStorage for web
17
101
  const defaultStorage: StorageInterface = {
18
102
  getItem: (key: string) => {
@@ -26,16 +110,83 @@ const defaultStorage: StorageInterface = {
26
110
  },
27
111
  };
28
112
 
29
- export interface LokalContextValue {
30
- locale: string;
31
- setLocale: (locale: string) => void;
32
- locales: string[];
33
- t: TranslateFunction;
34
- translations: LocaleData;
35
- isLoading: boolean;
113
+ // ============================================
114
+ // Helper Functions
115
+ // ============================================
116
+
117
+ /**
118
+ * Get plural category based on locale and count
119
+ */
120
+ function getPluralCategory(locale: string, count: number): PluralCategory {
121
+ // CLDR plural rules implementation
122
+ const rules: Record<string, (n: number) => PluralCategory> = {
123
+ en: (n) => n === 1 ? 'one' : 'other',
124
+ fr: (n) => n === 0 || n === 1 ? 'one' : 'many',
125
+ es: (n) => n === 1 ? 'one' : 'many',
126
+ de: (n) => n === 1 ? 'one' : 'other',
127
+ it: (n) => n === 1 ? 'one' : 'other',
128
+ ru: (n) => {
129
+ const mod10 = n % 10;
130
+ const mod100 = n % 100;
131
+ if (n === 1) return 'one';
132
+ if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) return 'few';
133
+ if (mod10 === 0 || mod10 >= 5 || mod10 >= 11 && mod10 <= 15) return 'many';
134
+ return 'other';
135
+ },
136
+ ar: (n) => {
137
+ if (n === 0) return 'zero';
138
+ if (n === 1) return 'one';
139
+ if (n === 2) return 'two';
140
+ const mod100 = n % 100;
141
+ if (mod100 >= 3 && mod100 <= 10) return 'few';
142
+ if (mod100 >= 11 && mod100 <= 99) return 'many';
143
+ return 'other';
144
+ },
145
+ };
146
+
147
+ const rule = rules[locale] || rules['en'];
148
+ return rule(count);
36
149
  }
37
150
 
38
- const LokalContext = createContext<LokalContextValue | null>(null);
151
+ /**
152
+ * Get nested value from object using dot notation
153
+ */
154
+ function getNestedValue(obj: any, path: string): string {
155
+ const keys = path.split('.');
156
+ let value: any = obj;
157
+
158
+ for (const k of keys) {
159
+ if (value && typeof value === 'object' && k in value) {
160
+ value = value[k];
161
+ } else {
162
+ return '';
163
+ }
164
+ }
165
+
166
+ return typeof value === 'string' ? value : '';
167
+ }
168
+
169
+ /**
170
+ * Set nested value in object using dot notation
171
+ */
172
+ function setNestedValue(obj: any, path: string, value: string): void {
173
+ const keys = path.split('.');
174
+ let current = obj;
175
+
176
+ for (let i = 0; i < keys.length - 1; i++) {
177
+ const key = keys[i];
178
+ if (!(key in current)) {
179
+ current[key] = {};
180
+ }
181
+ current = current[key];
182
+ }
183
+
184
+ current[keys[keys.length - 1]] = value;
185
+ }
186
+
187
+ // ============================================
188
+ // Provider Props
189
+ // ============================================
39
190
 
40
191
  export interface LokalProviderProps {
41
192
  children: ReactNode;
@@ -46,15 +197,20 @@ export interface LokalProviderProps {
46
197
  namespace?: string;
47
198
  defaultLocale?: string;
48
199
  onLocaleChange?: (locale: string) => void;
200
+ // SSR Support
201
+ initialLocale?: string;
202
+ initialTranslations?: LocaleData;
203
+ fallbackLocale?: string;
49
204
  }
50
205
 
51
206
  interface StoredTranslations {
52
207
  [locale: string]: LocaleData;
53
208
  }
54
209
 
55
- /**
56
- * LokalProvider - Provides localization context to your React app
57
- */
210
+ // ============================================
211
+ // LokalProvider Component
212
+ // ============================================
213
+
58
214
  export function LokalProvider({
59
215
  children,
60
216
  locale: initialLocale = 'en',
@@ -64,10 +220,23 @@ export function LokalProvider({
64
220
  namespace = 'locales',
65
221
  defaultLocale = 'en',
66
222
  onLocaleChange,
223
+ initialLocale: ssrInitialLocale,
224
+ initialTranslations: ssrInitialTranslations,
225
+ fallbackLocale = 'en',
67
226
  }: LokalProviderProps) {
68
- const [locale, setLocaleState] = useState<string>(initialLocale);
69
- const [translations, setTranslations] = useState<LocaleData>(initialTranslations);
70
- const [isLoading, setIsLoading] = useState<boolean>(true);
227
+ // Use SSR initial values if provided, otherwise use client-side state
228
+ const [locale, setLocaleState] = useState<string>(ssrInitialLocale || initialLocale);
229
+ const [translations, setTranslations] = useState<LocaleData>(ssrInitialTranslations || initialTranslations);
230
+ const [isLoading, setIsLoading] = useState<boolean>(!!ssrInitialLocale);
231
+ const [isHydrated, setIsHydrated] = useState<boolean>(!!ssrInitialLocale);
232
+
233
+ // Handle hydration for SSR
234
+ useEffect(() => {
235
+ if (ssrInitialLocale && !isHydrated) {
236
+ setIsHydrated(true);
237
+ setIsLoading(false);
238
+ }
239
+ }, [ssrInitialLocale, isHydrated]);
71
240
 
72
241
  // Load saved locale and translations from storage
73
242
  useEffect(() => {
@@ -76,22 +245,27 @@ export function LokalProvider({
76
245
  const savedLocale = await storage.getItem(`${namespace}-locale`);
77
246
  if (savedLocale && locales.includes(savedLocale)) {
78
247
  setLocaleState(savedLocale);
79
- } else {
248
+ } else if (!ssrInitialLocale) {
80
249
  // Try to detect from browser
81
- const browserLocale = navigator.language.split('-')[0];
82
- if (locales.includes(browserLocale)) {
83
- setLocaleState(browserLocale);
250
+ if (typeof navigator !== 'undefined') {
251
+ const browserLocale = navigator.language.split('-')[0];
252
+ if (locales.includes(browserLocale)) {
253
+ setLocaleState(browserLocale);
254
+ }
84
255
  }
85
256
  }
86
257
  } catch (error) {
87
258
  console.warn('Failed to load locale from storage:', error);
259
+ if (!ssrInitialLocale) {
260
+ setLocaleState(fallbackLocale);
261
+ }
88
262
  } finally {
89
263
  setIsLoading(false);
90
264
  }
91
265
  };
92
266
 
93
267
  loadLocale();
94
- }, [storage, locales, namespace]);
268
+ }, [storage, locales, namespace, ssrInitialLocale, fallbackLocale]);
95
269
 
96
270
  // Load translations for current locale
97
271
  useEffect(() => {
@@ -109,10 +283,10 @@ export function LokalProvider({
109
283
  }
110
284
  };
111
285
 
112
- if (!isLoading) {
286
+ if (!isLoading && !ssrInitialTranslations) {
113
287
  loadTranslations();
114
288
  }
115
- }, [locale, storage, namespace, isLoading]);
289
+ }, [locale, storage, namespace, isLoading, ssrInitialTranslations]);
116
290
 
117
291
  // Set locale and persist
118
292
  const setLocale = useCallback((newLocale: string) => {
@@ -122,28 +296,19 @@ export function LokalProvider({
122
296
  }
123
297
 
124
298
  setLocaleState(newLocale);
125
- storage.setItem(`${namespace}-locale`, newLocale);
299
+ storage.setItem(`${namespace}-locale`, newLocale).catch(console.warn);
126
300
 
127
301
  if (onLocaleChange) {
128
302
  onLocaleChange(newLocale);
129
303
  }
130
304
  }, [locales, storage, namespace, onLocaleChange]);
131
305
 
132
- // Translation function
133
- const t = useCallback<TranslateFunction>((key, params) => {
134
- const keys = key.split('.');
135
- let value: any = translations;
136
-
137
- for (const k of keys) {
138
- if (value && typeof value === 'object' && k in value) {
139
- value = value[k];
140
- } else {
141
- // Return key if not found
142
- return key;
143
- }
144
- }
306
+ // Basic translation function
307
+ const translate = useCallback((key: string, params?: Record<string, string | number>): string => {
308
+ const value = getNestedValue(translations, key);
145
309
 
146
- if (typeof value !== 'string') {
310
+ if (!value) {
311
+ // Return key if not found
147
312
  return key;
148
313
  }
149
314
 
@@ -158,6 +323,109 @@ export function LokalProvider({
158
323
  return value;
159
324
  }, [translations]);
160
325
 
326
+ // Extended translation function with pluralization
327
+ const translatePlural = useCallback((key: string, params: PluralParams): string => {
328
+ const { count, ...restParams } = params;
329
+ const pluralCategory = getPluralCategory(locale, count);
330
+
331
+ // Try plural-specific key first (e.g., "items_one", "items_other")
332
+ const pluralKey = `${key}_${pluralCategory}`;
333
+ let value = getNestedValue(translations, pluralKey);
334
+
335
+ // Fall back to base key if plural form not found
336
+ if (!value) {
337
+ value = getNestedValue(translations, key);
338
+ }
339
+
340
+ if (!value) {
341
+ return key;
342
+ }
343
+
344
+ // Replace all parameters including count
345
+ const allParams = { count, ...restParams };
346
+ return Object.entries(allParams).reduce(
347
+ (str, [paramKey, paramValue]) => str.replace(new RegExp(`{{${paramKey}}}`, 'g'), String(paramValue)),
348
+ value
349
+ );
350
+ }, [locale, translations]);
351
+
352
+ // Extended translation function with gender
353
+ const translateGender = useCallback((key: string, params: GenderParams): string => {
354
+ const { gender, ...restParams } = params;
355
+
356
+ // Try gender-specific key first (e.g., "user_male", "user_female")
357
+ const genderKey = `${key}_${gender}`;
358
+ let value = getNestedValue(translations, genderKey);
359
+
360
+ // Fall back to base key if gender form not found
361
+ if (!value) {
362
+ value = getNestedValue(translations, key);
363
+ }
364
+
365
+ if (!value) {
366
+ return key;
367
+ }
368
+
369
+ // Replace all parameters including gender
370
+ const allParams = { gender, ...restParams };
371
+ return Object.entries(allParams).reduce(
372
+ (str, [paramKey, paramValue]) => str.replace(new RegExp(`{{${paramKey}}}`, 'g'), String(paramValue)),
373
+ value
374
+ );
375
+ }, [translations]);
376
+
377
+ // Choice/select translation
378
+ const translateChoice = useCallback((key: string, params: { value: string | number }): string => {
379
+ const { value } = params;
380
+
381
+ // Try value-specific key first (e.g., "color_red", "color_blue")
382
+ const choiceKey = `${key}_${value}`;
383
+ let translation = getNestedValue(translations, choiceKey);
384
+
385
+ // Fall back to base key if choice not found
386
+ if (!translation) {
387
+ translation = getNestedValue(translations, key);
388
+ }
389
+
390
+ if (!translation) {
391
+ return key;
392
+ }
393
+
394
+ return translation;
395
+ }, [translations]);
396
+
397
+ // Extended translate function
398
+ const t = useCallback<ExtendedTranslateFunction>(Object.assign(
399
+ translate,
400
+ {
401
+ plural: translatePlural,
402
+ gender: translateGender,
403
+ choice: translateChoice,
404
+ }
405
+ ), [translate, translatePlural, translateGender, translateChoice]);
406
+
407
+ // Date formatting
408
+ const formatDate = useCallback((date: Date | string, options?: DateFormatOptions): string => {
409
+ const dateObj = typeof date === 'string' ? new Date(date) : date;
410
+
411
+ try {
412
+ return new Intl.DateTimeFormat(locale, options as Intl.DateTimeFormatOptions).format(dateObj);
413
+ } catch (error) {
414
+ console.warn('Date formatting failed:', error);
415
+ return dateObj.toLocaleDateString();
416
+ }
417
+ }, [locale]);
418
+
419
+ // Number formatting
420
+ const formatNumber = useCallback((num: number, options?: NumberFormatOptions): string => {
421
+ try {
422
+ return new Intl.NumberFormat(locale, options as Intl.NumberFormatOptions).format(num);
423
+ } catch (error) {
424
+ console.warn('Number formatting failed:', error);
425
+ return num.toString();
426
+ }
427
+ }, [locale]);
428
+
161
429
  const contextValue: LokalContextValue = {
162
430
  locale,
163
431
  setLocale,
@@ -165,6 +433,8 @@ export function LokalProvider({
165
433
  t,
166
434
  translations,
167
435
  isLoading,
436
+ formatDate,
437
+ formatNumber,
168
438
  };
169
439
 
170
440
  return (
@@ -174,8 +444,12 @@ export function LokalProvider({
174
444
  );
175
445
  }
176
446
 
447
+ // ============================================
448
+ // Hooks
449
+ // ============================================
450
+
177
451
  /**
178
- * Hook to access the Lokal context
452
+ * Hook to access the full Lokal context
179
453
  */
180
454
  export function useLokal(): LokalContextValue {
181
455
  const context = useContext(LokalContext);
@@ -187,4 +461,168 @@ export function useLokal(): LokalContextValue {
187
461
  return context;
188
462
  }
189
463
 
464
+ /**
465
+ * Hook to access only the translation function
466
+ */
467
+ export function useTranslate(): ExtendedTranslateFunction {
468
+ const { t } = useLokal();
469
+ return t;
470
+ }
471
+
472
+ /**
473
+ * Hook to access locale and setter
474
+ */
475
+ export function useLocale(): { locale: string; setLocale: (locale: string) => void; locales: string[] } {
476
+ const { locale, setLocale, locales } = useLokal();
477
+ return { locale, setLocale, locales };
478
+ }
479
+
480
+ /**
481
+ * Hook to access date/number formatters
482
+ */
483
+ export function useFormatters(): { formatDate: (date: Date | string, options?: DateFormatOptions) => string; formatNumber: (num: number, options?: NumberFormatOptions) => string } {
484
+ const { formatDate, formatNumber } = useLokal();
485
+ return { formatDate, formatNumber };
486
+ }
487
+
488
+ /**
489
+ * Hook to check if translations are loading
490
+ */
491
+ export function useIsLoading(): boolean {
492
+ const { isLoading } = useLokal();
493
+ return isLoading;
494
+ }
495
+
496
+ // ============================================
497
+ // T Component JSX Trans forlations
498
+ // ============================================
499
+
500
+ interface TComponentProps {
501
+ children: string;
502
+ params?: Record<string, string | number>;
503
+ plural?: number;
504
+ gender?: GenderCategory;
505
+ className?: string;
506
+ as?: React.ElementType;
507
+ }
508
+
509
+ /**
510
+ * T Component - Translate content directly in JSX
511
+ * Usage: <T>hello_world</T> or <T params={{name: 'John'}}>greeting</T>
512
+ */
513
+ export function T({
514
+ children,
515
+ params,
516
+ plural,
517
+ gender,
518
+ className,
519
+ as: Component = 'span'
520
+ }: TComponentProps) {
521
+ const { t } = useLokal();
522
+
523
+ let translatedText: string;
524
+
525
+ if (plural !== undefined) {
526
+ translatedText = t.plural(children, { count: plural, ...(params || {}) });
527
+ } else if (gender) {
528
+ translatedText = t.gender(children, { gender, ...(params || {}) });
529
+ } else {
530
+ translatedText = t(children as any, params);
531
+ }
532
+
533
+ return (
534
+ <Component className={className}>
535
+ {translatedText}
536
+ </Component>
537
+ );
538
+ }
539
+
540
+ // ============================================
541
+ // Lazy Translation Hook
542
+ // ============================================
543
+
544
+ /**
545
+ * Hook for lazy-loaded translations (useful for code splitting)
546
+ */
547
+ export function useLazyTranslations(namespace: string) {
548
+ const { t, translations, isLoading } = useLokal();
549
+
550
+ const translate = useCallback((key: string, params?: Record<string, string | number>): string => {
551
+ const namespacedKey = `${namespace}.${key}`;
552
+ const value = getNestedValue(translations, namespacedKey);
553
+
554
+ if (!value) {
555
+ return key;
556
+ }
557
+
558
+ if (params) {
559
+ return Object.entries(params).reduce(
560
+ (str, [paramKey, paramValue]) =>
561
+ str.replace(new RegExp(`{{${paramKey}}}`, 'g'), String(paramValue)),
562
+ value
563
+ );
564
+ }
565
+
566
+ return value;
567
+ }, [translations, namespace]);
568
+
569
+ return { t: translate, isLoading };
570
+ }
571
+
572
+ // ============================================
573
+ // SSR Hydration Helper
574
+ // ============================================
575
+
576
+ /**
577
+ * Hook to handle SSR hydration
578
+ * Returns true until client-side hydration is complete
579
+ */
580
+ export function useHydrated(): boolean {
581
+ const [hydrated, setHydrated] = useState(false);
582
+
583
+ useEffect(() => {
584
+ setHydrated(true);
585
+ }, []);
586
+
587
+ return hydrated;
588
+ }
589
+
590
+ /**
591
+ * Hook to get translations only after hydration
592
+ * Prevents hydration mismatches
593
+ */
594
+ export function useSafeTranslations(): { t: ExtendedTranslateFunction; isHydrated: boolean } {
595
+ const { t } = useLokal();
596
+ const isHydrated = useHydrated();
597
+
598
+ // Simple wrapper that returns key during SSR to prevent mismatch
599
+ const safeT = (key: string, params?: Record<string, string | number>): string => {
600
+ if (!isHydrated) {
601
+ return key;
602
+ }
603
+ return t(key, params);
604
+ };
605
+
606
+ safeT.plural = (key: string, params: PluralParams): string => {
607
+ if (!isHydrated) return key;
608
+ return t.plural(key, params);
609
+ };
610
+
611
+ safeT.gender = (key: string, params: GenderParams): string => {
612
+ if (!isHydrated) return key;
613
+ return t.gender(key, params);
614
+ };
615
+
616
+ safeT.choice = (key: string, params: { value: string | number }): string => {
617
+ if (!isHydrated) return key;
618
+ return t.choice(key, params);
619
+ };
620
+
621
+ return { t: safeT as ExtendedTranslateFunction, isHydrated };
622
+ }
623
+
624
+ // ============================================
625
+ // Default Export
626
+ // ============================================
627
+
190
628
  export default LokalContext;