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.
- package/dist/index.d.mts +144 -0
- package/dist/index.d.ts +144 -0
- package/dist/index.js +357 -44
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +369 -36
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/context/LokalContext.tsx +475 -37
- package/src/hooks/useStandaloneTranslate.ts +117 -0
- package/src/index.ts +30 -4
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
const [
|
|
70
|
-
const [
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
//
|
|
133
|
-
const
|
|
134
|
-
const
|
|
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 (
|
|
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;
|