koto-react 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.
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/dist/Provider.d.ts +16 -0
- package/dist/Provider.d.ts.map +1 -0
- package/dist/constants/locales.d.ts +20 -0
- package/dist/constants/locales.d.ts.map +1 -0
- package/dist/hoc.d.ts +33 -0
- package/dist/hoc.d.ts.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.esm.js +903 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +931 -0
- package/dist/index.js.map +1 -0
- package/dist/locales.d.ts +29 -0
- package/dist/locales.d.ts.map +1 -0
- package/dist/storage.d.ts +21 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/t.d.ts +52 -0
- package/dist/t.d.ts.map +1 -0
- package/dist/types.d.ts +51 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils.d.ts +22 -0
- package/dist/utils.d.ts.map +1 -0
- package/package.json +56 -0
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
const instanceOfAny = (object, constructors) => constructors.some((c) => object instanceof c);
|
|
4
|
+
|
|
5
|
+
let idbProxyableTypes;
|
|
6
|
+
let cursorAdvanceMethods;
|
|
7
|
+
// This is a function to prevent it throwing up in node environments.
|
|
8
|
+
function getIdbProxyableTypes() {
|
|
9
|
+
return (idbProxyableTypes ||
|
|
10
|
+
(idbProxyableTypes = [
|
|
11
|
+
IDBDatabase,
|
|
12
|
+
IDBObjectStore,
|
|
13
|
+
IDBIndex,
|
|
14
|
+
IDBCursor,
|
|
15
|
+
IDBTransaction,
|
|
16
|
+
]));
|
|
17
|
+
}
|
|
18
|
+
// This is a function to prevent it throwing up in node environments.
|
|
19
|
+
function getCursorAdvanceMethods() {
|
|
20
|
+
return (cursorAdvanceMethods ||
|
|
21
|
+
(cursorAdvanceMethods = [
|
|
22
|
+
IDBCursor.prototype.advance,
|
|
23
|
+
IDBCursor.prototype.continue,
|
|
24
|
+
IDBCursor.prototype.continuePrimaryKey,
|
|
25
|
+
]));
|
|
26
|
+
}
|
|
27
|
+
const transactionDoneMap = new WeakMap();
|
|
28
|
+
const transformCache = new WeakMap();
|
|
29
|
+
const reverseTransformCache = new WeakMap();
|
|
30
|
+
function promisifyRequest(request) {
|
|
31
|
+
const promise = new Promise((resolve, reject) => {
|
|
32
|
+
const unlisten = () => {
|
|
33
|
+
request.removeEventListener('success', success);
|
|
34
|
+
request.removeEventListener('error', error);
|
|
35
|
+
};
|
|
36
|
+
const success = () => {
|
|
37
|
+
resolve(wrap(request.result));
|
|
38
|
+
unlisten();
|
|
39
|
+
};
|
|
40
|
+
const error = () => {
|
|
41
|
+
reject(request.error);
|
|
42
|
+
unlisten();
|
|
43
|
+
};
|
|
44
|
+
request.addEventListener('success', success);
|
|
45
|
+
request.addEventListener('error', error);
|
|
46
|
+
});
|
|
47
|
+
// This mapping exists in reverseTransformCache but doesn't exist in transformCache. This
|
|
48
|
+
// is because we create many promises from a single IDBRequest.
|
|
49
|
+
reverseTransformCache.set(promise, request);
|
|
50
|
+
return promise;
|
|
51
|
+
}
|
|
52
|
+
function cacheDonePromiseForTransaction(tx) {
|
|
53
|
+
// Early bail if we've already created a done promise for this transaction.
|
|
54
|
+
if (transactionDoneMap.has(tx))
|
|
55
|
+
return;
|
|
56
|
+
const done = new Promise((resolve, reject) => {
|
|
57
|
+
const unlisten = () => {
|
|
58
|
+
tx.removeEventListener('complete', complete);
|
|
59
|
+
tx.removeEventListener('error', error);
|
|
60
|
+
tx.removeEventListener('abort', error);
|
|
61
|
+
};
|
|
62
|
+
const complete = () => {
|
|
63
|
+
resolve();
|
|
64
|
+
unlisten();
|
|
65
|
+
};
|
|
66
|
+
const error = () => {
|
|
67
|
+
reject(tx.error || new DOMException('AbortError', 'AbortError'));
|
|
68
|
+
unlisten();
|
|
69
|
+
};
|
|
70
|
+
tx.addEventListener('complete', complete);
|
|
71
|
+
tx.addEventListener('error', error);
|
|
72
|
+
tx.addEventListener('abort', error);
|
|
73
|
+
});
|
|
74
|
+
// Cache it for later retrieval.
|
|
75
|
+
transactionDoneMap.set(tx, done);
|
|
76
|
+
}
|
|
77
|
+
let idbProxyTraps = {
|
|
78
|
+
get(target, prop, receiver) {
|
|
79
|
+
if (target instanceof IDBTransaction) {
|
|
80
|
+
// Special handling for transaction.done.
|
|
81
|
+
if (prop === 'done')
|
|
82
|
+
return transactionDoneMap.get(target);
|
|
83
|
+
// Make tx.store return the only store in the transaction, or undefined if there are many.
|
|
84
|
+
if (prop === 'store') {
|
|
85
|
+
return receiver.objectStoreNames[1]
|
|
86
|
+
? undefined
|
|
87
|
+
: receiver.objectStore(receiver.objectStoreNames[0]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Else transform whatever we get back.
|
|
91
|
+
return wrap(target[prop]);
|
|
92
|
+
},
|
|
93
|
+
set(target, prop, value) {
|
|
94
|
+
target[prop] = value;
|
|
95
|
+
return true;
|
|
96
|
+
},
|
|
97
|
+
has(target, prop) {
|
|
98
|
+
if (target instanceof IDBTransaction &&
|
|
99
|
+
(prop === 'done' || prop === 'store')) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
return prop in target;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
function replaceTraps(callback) {
|
|
106
|
+
idbProxyTraps = callback(idbProxyTraps);
|
|
107
|
+
}
|
|
108
|
+
function wrapFunction(func) {
|
|
109
|
+
// Due to expected object equality (which is enforced by the caching in `wrap`), we
|
|
110
|
+
// only create one new func per func.
|
|
111
|
+
// Cursor methods are special, as the behaviour is a little more different to standard IDB. In
|
|
112
|
+
// IDB, you advance the cursor and wait for a new 'success' on the IDBRequest that gave you the
|
|
113
|
+
// cursor. It's kinda like a promise that can resolve with many values. That doesn't make sense
|
|
114
|
+
// with real promises, so each advance methods returns a new promise for the cursor object, or
|
|
115
|
+
// undefined if the end of the cursor has been reached.
|
|
116
|
+
if (getCursorAdvanceMethods().includes(func)) {
|
|
117
|
+
return function (...args) {
|
|
118
|
+
// Calling the original function with the proxy as 'this' causes ILLEGAL INVOCATION, so we use
|
|
119
|
+
// the original object.
|
|
120
|
+
func.apply(unwrap(this), args);
|
|
121
|
+
return wrap(this.request);
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return function (...args) {
|
|
125
|
+
// Calling the original function with the proxy as 'this' causes ILLEGAL INVOCATION, so we use
|
|
126
|
+
// the original object.
|
|
127
|
+
return wrap(func.apply(unwrap(this), args));
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function transformCachableValue(value) {
|
|
131
|
+
if (typeof value === 'function')
|
|
132
|
+
return wrapFunction(value);
|
|
133
|
+
// This doesn't return, it just creates a 'done' promise for the transaction,
|
|
134
|
+
// which is later returned for transaction.done (see idbObjectHandler).
|
|
135
|
+
if (value instanceof IDBTransaction)
|
|
136
|
+
cacheDonePromiseForTransaction(value);
|
|
137
|
+
if (instanceOfAny(value, getIdbProxyableTypes()))
|
|
138
|
+
return new Proxy(value, idbProxyTraps);
|
|
139
|
+
// Return the same value back if we're not going to transform it.
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
function wrap(value) {
|
|
143
|
+
// We sometimes generate multiple promises from a single IDBRequest (eg when cursoring), because
|
|
144
|
+
// IDB is weird and a single IDBRequest can yield many responses, so these can't be cached.
|
|
145
|
+
if (value instanceof IDBRequest)
|
|
146
|
+
return promisifyRequest(value);
|
|
147
|
+
// If we've already transformed this value before, reuse the transformed value.
|
|
148
|
+
// This is faster, but it also provides object equality.
|
|
149
|
+
if (transformCache.has(value))
|
|
150
|
+
return transformCache.get(value);
|
|
151
|
+
const newValue = transformCachableValue(value);
|
|
152
|
+
// Not all types are transformed.
|
|
153
|
+
// These may be primitive types, so they can't be WeakMap keys.
|
|
154
|
+
if (newValue !== value) {
|
|
155
|
+
transformCache.set(value, newValue);
|
|
156
|
+
reverseTransformCache.set(newValue, value);
|
|
157
|
+
}
|
|
158
|
+
return newValue;
|
|
159
|
+
}
|
|
160
|
+
const unwrap = (value) => reverseTransformCache.get(value);
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Open a database.
|
|
164
|
+
*
|
|
165
|
+
* @param name Name of the database.
|
|
166
|
+
* @param version Schema version.
|
|
167
|
+
* @param callbacks Additional callbacks.
|
|
168
|
+
*/
|
|
169
|
+
function openDB(name, version, { blocked, upgrade, blocking, terminated } = {}) {
|
|
170
|
+
const request = indexedDB.open(name, version);
|
|
171
|
+
const openPromise = wrap(request);
|
|
172
|
+
if (upgrade) {
|
|
173
|
+
request.addEventListener('upgradeneeded', (event) => {
|
|
174
|
+
upgrade(wrap(request.result), event.oldVersion, event.newVersion, wrap(request.transaction), event);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (blocked) {
|
|
178
|
+
request.addEventListener('blocked', (event) => blocked(
|
|
179
|
+
// Casting due to https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1405
|
|
180
|
+
event.oldVersion, event.newVersion, event));
|
|
181
|
+
}
|
|
182
|
+
openPromise
|
|
183
|
+
.then((db) => {
|
|
184
|
+
if (terminated)
|
|
185
|
+
db.addEventListener('close', () => terminated());
|
|
186
|
+
if (blocking) {
|
|
187
|
+
db.addEventListener('versionchange', (event) => blocking(event.oldVersion, event.newVersion, event));
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
.catch(() => { });
|
|
191
|
+
return openPromise;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const readMethods = ['get', 'getKey', 'getAll', 'getAllKeys', 'count'];
|
|
195
|
+
const writeMethods = ['put', 'add', 'delete', 'clear'];
|
|
196
|
+
const cachedMethods = new Map();
|
|
197
|
+
function getMethod(target, prop) {
|
|
198
|
+
if (!(target instanceof IDBDatabase &&
|
|
199
|
+
!(prop in target) &&
|
|
200
|
+
typeof prop === 'string')) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (cachedMethods.get(prop))
|
|
204
|
+
return cachedMethods.get(prop);
|
|
205
|
+
const targetFuncName = prop.replace(/FromIndex$/, '');
|
|
206
|
+
const useIndex = prop !== targetFuncName;
|
|
207
|
+
const isWrite = writeMethods.includes(targetFuncName);
|
|
208
|
+
if (
|
|
209
|
+
// Bail if the target doesn't exist on the target. Eg, getAll isn't in Edge.
|
|
210
|
+
!(targetFuncName in (useIndex ? IDBIndex : IDBObjectStore).prototype) ||
|
|
211
|
+
!(isWrite || readMethods.includes(targetFuncName))) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const method = async function (storeName, ...args) {
|
|
215
|
+
// isWrite ? 'readwrite' : undefined gzipps better, but fails in Edge :(
|
|
216
|
+
const tx = this.transaction(storeName, isWrite ? 'readwrite' : 'readonly');
|
|
217
|
+
let target = tx.store;
|
|
218
|
+
if (useIndex)
|
|
219
|
+
target = target.index(args.shift());
|
|
220
|
+
// Must reject if op rejects.
|
|
221
|
+
// If it's a write operation, must reject if tx.done rejects.
|
|
222
|
+
// Must reject with op rejection first.
|
|
223
|
+
// Must resolve with op value.
|
|
224
|
+
// Must handle both promises (no unhandled rejections)
|
|
225
|
+
return (await Promise.all([
|
|
226
|
+
target[targetFuncName](...args),
|
|
227
|
+
isWrite && tx.done,
|
|
228
|
+
]))[0];
|
|
229
|
+
};
|
|
230
|
+
cachedMethods.set(prop, method);
|
|
231
|
+
return method;
|
|
232
|
+
}
|
|
233
|
+
replaceTraps((oldTraps) => ({
|
|
234
|
+
...oldTraps,
|
|
235
|
+
get: (target, prop, receiver) => getMethod(target, prop) || oldTraps.get(target, prop, receiver),
|
|
236
|
+
has: (target, prop) => !!getMethod(target, prop) || oldTraps.has(target, prop),
|
|
237
|
+
}));
|
|
238
|
+
|
|
239
|
+
const advanceMethodProps = ['continue', 'continuePrimaryKey', 'advance'];
|
|
240
|
+
const methodMap = {};
|
|
241
|
+
const advanceResults = new WeakMap();
|
|
242
|
+
const ittrProxiedCursorToOriginalProxy = new WeakMap();
|
|
243
|
+
const cursorIteratorTraps = {
|
|
244
|
+
get(target, prop) {
|
|
245
|
+
if (!advanceMethodProps.includes(prop))
|
|
246
|
+
return target[prop];
|
|
247
|
+
let cachedFunc = methodMap[prop];
|
|
248
|
+
if (!cachedFunc) {
|
|
249
|
+
cachedFunc = methodMap[prop] = function (...args) {
|
|
250
|
+
advanceResults.set(this, ittrProxiedCursorToOriginalProxy.get(this)[prop](...args));
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return cachedFunc;
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
async function* iterate(...args) {
|
|
257
|
+
// tslint:disable-next-line:no-this-assignment
|
|
258
|
+
let cursor = this;
|
|
259
|
+
if (!(cursor instanceof IDBCursor)) {
|
|
260
|
+
cursor = await cursor.openCursor(...args);
|
|
261
|
+
}
|
|
262
|
+
if (!cursor)
|
|
263
|
+
return;
|
|
264
|
+
cursor = cursor;
|
|
265
|
+
const proxiedCursor = new Proxy(cursor, cursorIteratorTraps);
|
|
266
|
+
ittrProxiedCursorToOriginalProxy.set(proxiedCursor, cursor);
|
|
267
|
+
// Map this double-proxy back to the original, so other cursor methods work.
|
|
268
|
+
reverseTransformCache.set(proxiedCursor, unwrap(cursor));
|
|
269
|
+
while (cursor) {
|
|
270
|
+
yield proxiedCursor;
|
|
271
|
+
// If one of the advancing methods was not called, call continue().
|
|
272
|
+
cursor = await (advanceResults.get(proxiedCursor) || cursor.continue());
|
|
273
|
+
advanceResults.delete(proxiedCursor);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function isIteratorProp(target, prop) {
|
|
277
|
+
return ((prop === Symbol.asyncIterator &&
|
|
278
|
+
instanceOfAny(target, [IDBIndex, IDBObjectStore, IDBCursor])) ||
|
|
279
|
+
(prop === 'iterate' && instanceOfAny(target, [IDBIndex, IDBObjectStore])));
|
|
280
|
+
}
|
|
281
|
+
replaceTraps((oldTraps) => ({
|
|
282
|
+
...oldTraps,
|
|
283
|
+
get(target, prop, receiver) {
|
|
284
|
+
if (isIteratorProp(target, prop))
|
|
285
|
+
return iterate;
|
|
286
|
+
return oldTraps.get(target, prop, receiver);
|
|
287
|
+
},
|
|
288
|
+
has(target, prop) {
|
|
289
|
+
return isIteratorProp(target, prop) || oldTraps.has(target, prop);
|
|
290
|
+
},
|
|
291
|
+
}));
|
|
292
|
+
|
|
293
|
+
const DB_NAME = 'koto-translations';
|
|
294
|
+
const DB_VERSION = 3;
|
|
295
|
+
const STORE_NAME = 'translations';
|
|
296
|
+
const CACHE_DURATION = 1000 * 60 * 60; // 1 hour
|
|
297
|
+
class TranslationStorage {
|
|
298
|
+
constructor() {
|
|
299
|
+
this.db = null;
|
|
300
|
+
}
|
|
301
|
+
async init() {
|
|
302
|
+
if (this.db)
|
|
303
|
+
return;
|
|
304
|
+
this.db = await openDB(DB_NAME, DB_VERSION, {
|
|
305
|
+
upgrade(db) {
|
|
306
|
+
// Create or keep translations store
|
|
307
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
308
|
+
db.createObjectStore(STORE_NAME);
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
async getTranslations(locale) {
|
|
314
|
+
await this.init();
|
|
315
|
+
if (!this.db)
|
|
316
|
+
return null;
|
|
317
|
+
try {
|
|
318
|
+
const stored = await this.db.get(STORE_NAME, locale);
|
|
319
|
+
if (!stored)
|
|
320
|
+
return null;
|
|
321
|
+
// Check if cache is still valid
|
|
322
|
+
const now = Date.now();
|
|
323
|
+
if (now - stored.timestamp > CACHE_DURATION) {
|
|
324
|
+
// Cache expired
|
|
325
|
+
await this.db.delete(STORE_NAME, locale);
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
return stored;
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
console.error('Error getting translations from IndexedDB:', error);
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async setTranslations(locale, translations, apiResponse) {
|
|
336
|
+
await this.init();
|
|
337
|
+
if (!this.db)
|
|
338
|
+
return;
|
|
339
|
+
try {
|
|
340
|
+
// Process translations based on API response format
|
|
341
|
+
const processedTranslations = this.processTranslations(translations);
|
|
342
|
+
const stored = {
|
|
343
|
+
locale,
|
|
344
|
+
translations: processedTranslations,
|
|
345
|
+
timestamp: Date.now(),
|
|
346
|
+
version: apiResponse?.version,
|
|
347
|
+
};
|
|
348
|
+
// Include metadata if provided in API response
|
|
349
|
+
if (apiResponse?.localeInfo || apiResponse?.localeName) {
|
|
350
|
+
stored.metadata = {
|
|
351
|
+
name: apiResponse.localeName || apiResponse.localeInfo?.name,
|
|
352
|
+
nativeName: apiResponse.localeNativeName || apiResponse.localeInfo?.nativeName,
|
|
353
|
+
direction: apiResponse.direction || apiResponse.localeInfo?.direction || 'ltr',
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
await this.db.put(STORE_NAME, stored, locale);
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
console.error('Error storing translations in IndexedDB:', error);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
processTranslations(translations) {
|
|
363
|
+
// Handle different response formats
|
|
364
|
+
if (typeof translations === 'object' && translations !== null) {
|
|
365
|
+
// Check if it's already in the right format
|
|
366
|
+
if (this.isNestedStructure(translations)) {
|
|
367
|
+
return translations;
|
|
368
|
+
}
|
|
369
|
+
// Check if it's a flat key-value structure
|
|
370
|
+
if (this.isFlatStructure(translations)) {
|
|
371
|
+
return this.buildNestedFromFlat(translations);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Return as-is if we can't determine the structure
|
|
375
|
+
return translations;
|
|
376
|
+
}
|
|
377
|
+
isNestedStructure(obj) {
|
|
378
|
+
// Check if any value is an object (nested structure)
|
|
379
|
+
return Object.values(obj).some(val => typeof val === 'object' && val !== null && !Array.isArray(val));
|
|
380
|
+
}
|
|
381
|
+
isFlatStructure(obj) {
|
|
382
|
+
// Check if all values are strings and keys contain dots
|
|
383
|
+
return Object.entries(obj).every(([, val]) => typeof val === 'string') && Object.keys(obj).some(key => key.includes('.'));
|
|
384
|
+
}
|
|
385
|
+
buildNestedFromFlat(flatData) {
|
|
386
|
+
const result = {};
|
|
387
|
+
for (const [key, value] of Object.entries(flatData)) {
|
|
388
|
+
const keys = key.split('.');
|
|
389
|
+
let current = result;
|
|
390
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
391
|
+
const k = keys[i];
|
|
392
|
+
if (!(k in current)) {
|
|
393
|
+
current[k] = {};
|
|
394
|
+
}
|
|
395
|
+
current = current[k];
|
|
396
|
+
}
|
|
397
|
+
current[keys[keys.length - 1]] = value;
|
|
398
|
+
}
|
|
399
|
+
return result;
|
|
400
|
+
}
|
|
401
|
+
async clearTranslations(locale) {
|
|
402
|
+
await this.init();
|
|
403
|
+
if (!this.db)
|
|
404
|
+
return;
|
|
405
|
+
try {
|
|
406
|
+
if (locale) {
|
|
407
|
+
await this.db.delete(STORE_NAME, locale);
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
await this.db.clear(STORE_NAME);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
console.error('Error clearing translations from IndexedDB:', error);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
async getAllLocales() {
|
|
418
|
+
await this.init();
|
|
419
|
+
if (!this.db)
|
|
420
|
+
return [];
|
|
421
|
+
try {
|
|
422
|
+
return await this.db.getAllKeys(STORE_NAME);
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
console.error('Error getting locales from IndexedDB:', error);
|
|
426
|
+
return [];
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async getSupportedLocales() {
|
|
430
|
+
await this.init();
|
|
431
|
+
if (!this.db)
|
|
432
|
+
return [];
|
|
433
|
+
try {
|
|
434
|
+
const allData = await this.db.getAll(STORE_NAME);
|
|
435
|
+
return allData.map(data => ({
|
|
436
|
+
code: data.locale,
|
|
437
|
+
metadata: data.metadata,
|
|
438
|
+
}));
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
console.error('Error getting supported locales:', error);
|
|
442
|
+
return [];
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
async processApiResponse(locale, response) {
|
|
446
|
+
// Handle different response formats dynamically
|
|
447
|
+
let translations = {};
|
|
448
|
+
// Check for translations in various possible locations
|
|
449
|
+
if (response.translations) {
|
|
450
|
+
translations = response.translations;
|
|
451
|
+
}
|
|
452
|
+
else if (response.data) {
|
|
453
|
+
translations = response.data;
|
|
454
|
+
}
|
|
455
|
+
else if (typeof response === 'object' && !response.error) {
|
|
456
|
+
// Direct translation object - must be TranslationData format
|
|
457
|
+
translations = response;
|
|
458
|
+
}
|
|
459
|
+
// Store with API metadata
|
|
460
|
+
await this.setTranslations(locale, translations, response);
|
|
461
|
+
return this.processTranslations(translations);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const storage = new TranslationStorage();
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Get a nested translation value using dot notation
|
|
468
|
+
* @param translations - The translations object
|
|
469
|
+
* @param key - The dot-notated key (e.g., 'checkout.payment.title')
|
|
470
|
+
* @returns The translation value or null if not found
|
|
471
|
+
*/
|
|
472
|
+
function getNestedTranslation(translations, key) {
|
|
473
|
+
const keys = key.split('.');
|
|
474
|
+
let current = translations;
|
|
475
|
+
for (const k of keys) {
|
|
476
|
+
if (current && typeof current === 'object' && k in current) {
|
|
477
|
+
current = current[k];
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return typeof current === 'string' ? current : null;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Flatten nested translation object
|
|
487
|
+
* @param obj - The nested translation object
|
|
488
|
+
* @param prefix - The prefix for keys
|
|
489
|
+
* @returns Flattened object with dot-notated keys
|
|
490
|
+
*/
|
|
491
|
+
function flattenTranslations(obj, prefix = '') {
|
|
492
|
+
const flattened = {};
|
|
493
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
494
|
+
const newKey = prefix ? `${prefix}.${key}` : key;
|
|
495
|
+
if (typeof value === 'string') {
|
|
496
|
+
flattened[newKey] = value;
|
|
497
|
+
}
|
|
498
|
+
else if (typeof value === 'object' && value !== null) {
|
|
499
|
+
Object.assign(flattened, flattenTranslations(value, newKey));
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return flattened;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Unflatten dot-notated keys to nested object
|
|
506
|
+
* @param obj - The flattened object
|
|
507
|
+
* @returns Nested translation object
|
|
508
|
+
*/
|
|
509
|
+
function unflattenTranslations(obj) {
|
|
510
|
+
const result = {};
|
|
511
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
512
|
+
const keys = key.split('.');
|
|
513
|
+
let current = result;
|
|
514
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
515
|
+
const k = keys[i];
|
|
516
|
+
if (!(k in current)) {
|
|
517
|
+
current[k] = {};
|
|
518
|
+
}
|
|
519
|
+
current = current[k];
|
|
520
|
+
}
|
|
521
|
+
current[keys[keys.length - 1]] = value;
|
|
522
|
+
}
|
|
523
|
+
return result;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Available locales following BCP 47 standard (IETF language tags)
|
|
528
|
+
* Format: language[-script][-region]
|
|
529
|
+
* Examples: en, en-US, zh-CN, zh-Hans-CN
|
|
530
|
+
*
|
|
531
|
+
* These locale codes must match exactly what's in your database
|
|
532
|
+
* Following the same standard as react-i18next
|
|
533
|
+
*/
|
|
534
|
+
const AVAILABLE_LOCALES = [
|
|
535
|
+
// English variants
|
|
536
|
+
{ code: 'en', name: 'English', nativeName: 'English', direction: 'ltr', enabled: true },
|
|
537
|
+
{ code: 'en-US', name: 'English (US)', nativeName: 'English (US)', direction: 'ltr', enabled: true },
|
|
538
|
+
{ code: 'en-GB', name: 'English (UK)', nativeName: 'English (UK)', direction: 'ltr', enabled: true },
|
|
539
|
+
// Chinese variants - using script subtags
|
|
540
|
+
{ code: 'zh', name: 'Chinese', nativeName: '中文', direction: 'ltr', enabled: true },
|
|
541
|
+
{ code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文', direction: 'ltr', enabled: true },
|
|
542
|
+
{ code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文', direction: 'ltr', enabled: true },
|
|
543
|
+
{ code: 'zh-Hans', name: 'Chinese (Simplified)', nativeName: '简体中文', direction: 'ltr', enabled: true },
|
|
544
|
+
{ code: 'zh-Hant', name: 'Chinese (Traditional)', nativeName: '繁體中文', direction: 'ltr', enabled: true },
|
|
545
|
+
// Spanish variants
|
|
546
|
+
{ code: 'es', name: 'Spanish', nativeName: 'Español', direction: 'ltr', enabled: true },
|
|
547
|
+
{ code: 'es-ES', name: 'Spanish (Spain)', nativeName: 'Español (España)', direction: 'ltr', enabled: true },
|
|
548
|
+
{ code: 'es-MX', name: 'Spanish (Mexico)', nativeName: 'Español (México)', direction: 'ltr', enabled: true },
|
|
549
|
+
// Portuguese variants
|
|
550
|
+
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', direction: 'ltr', enabled: true },
|
|
551
|
+
{ code: 'pt-BR', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)', direction: 'ltr', enabled: true },
|
|
552
|
+
{ code: 'pt-PT', name: 'Portuguese (Portugal)', nativeName: 'Português (Portugal)', direction: 'ltr', enabled: true },
|
|
553
|
+
// French variants
|
|
554
|
+
{ code: 'fr', name: 'French', nativeName: 'Français', direction: 'ltr', enabled: true },
|
|
555
|
+
{ code: 'fr-FR', name: 'French (France)', nativeName: 'Français (France)', direction: 'ltr', enabled: true },
|
|
556
|
+
{ code: 'fr-CA', name: 'French (Canada)', nativeName: 'Français (Canada)', direction: 'ltr', enabled: true },
|
|
557
|
+
// German variants
|
|
558
|
+
{ code: 'de', name: 'German', nativeName: 'Deutsch', direction: 'ltr', enabled: true },
|
|
559
|
+
{ code: 'de-DE', name: 'German (Germany)', nativeName: 'Deutsch (Deutschland)', direction: 'ltr', enabled: true },
|
|
560
|
+
{ code: 'de-AT', name: 'German (Austria)', nativeName: 'Deutsch (Österreich)', direction: 'ltr', enabled: true },
|
|
561
|
+
{ code: 'de-CH', name: 'German (Switzerland)', nativeName: 'Deutsch (Schweiz)', direction: 'ltr', enabled: true },
|
|
562
|
+
// Other languages
|
|
563
|
+
{ code: 'it', name: 'Italian', nativeName: 'Italiano', direction: 'ltr', enabled: true },
|
|
564
|
+
{ code: 'it-IT', name: 'Italian (Italy)', nativeName: 'Italiano (Italia)', direction: 'ltr', enabled: true },
|
|
565
|
+
{ code: 'ja', name: 'Japanese', nativeName: '日本語', direction: 'ltr', enabled: true },
|
|
566
|
+
{ code: 'ja-JP', name: 'Japanese (Japan)', nativeName: '日本語 (日本)', direction: 'ltr', enabled: true },
|
|
567
|
+
{ code: 'ko', name: 'Korean', nativeName: '한국어', direction: 'ltr', enabled: true },
|
|
568
|
+
{ code: 'ko-KR', name: 'Korean (Korea)', nativeName: '한국어 (대한민국)', direction: 'ltr', enabled: true },
|
|
569
|
+
{ code: 'ru', name: 'Russian', nativeName: 'Русский', direction: 'ltr', enabled: true },
|
|
570
|
+
{ code: 'ru-RU', name: 'Russian (Russia)', nativeName: 'Русский (Россия)', direction: 'ltr', enabled: true },
|
|
571
|
+
// RTL languages
|
|
572
|
+
{ code: 'ar', name: 'Arabic', nativeName: 'العربية', direction: 'rtl', enabled: true },
|
|
573
|
+
{ code: 'ar-SA', name: 'Arabic (Saudi Arabia)', nativeName: 'العربية (المملكة العربية السعودية)', direction: 'rtl', enabled: true },
|
|
574
|
+
{ code: 'ar-AE', name: 'Arabic (UAE)', nativeName: 'العربية (الإمارات)', direction: 'rtl', enabled: true },
|
|
575
|
+
{ code: 'he', name: 'Hebrew', nativeName: 'עברית', direction: 'rtl', enabled: true },
|
|
576
|
+
{ code: 'he-IL', name: 'Hebrew (Israel)', nativeName: 'עברית (ישראל)', direction: 'rtl', enabled: true },
|
|
577
|
+
// Other Asian languages
|
|
578
|
+
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', direction: 'ltr', enabled: true },
|
|
579
|
+
{ code: 'hi-IN', name: 'Hindi (India)', nativeName: 'हिन्दी (भारत)', direction: 'ltr', enabled: true },
|
|
580
|
+
{ code: 'th', name: 'Thai', nativeName: 'ไทย', direction: 'ltr', enabled: true },
|
|
581
|
+
{ code: 'th-TH', name: 'Thai (Thailand)', nativeName: 'ไทย (ประเทศไทย)', direction: 'ltr', enabled: true },
|
|
582
|
+
{ code: 'vi', name: 'Vietnamese', nativeName: 'Tiếng Việt', direction: 'ltr', enabled: true },
|
|
583
|
+
{ code: 'vi-VN', name: 'Vietnamese (Vietnam)', nativeName: 'Tiếng Việt (Việt Nam)', direction: 'ltr', enabled: true },
|
|
584
|
+
{ code: 'id', name: 'Indonesian', nativeName: 'Bahasa Indonesia', direction: 'ltr', enabled: true },
|
|
585
|
+
{ code: 'id-ID', name: 'Indonesian (Indonesia)', nativeName: 'Bahasa Indonesia (Indonesia)', direction: 'ltr', enabled: true },
|
|
586
|
+
// European languages
|
|
587
|
+
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands', direction: 'ltr', enabled: true },
|
|
588
|
+
{ code: 'nl-NL', name: 'Dutch (Netherlands)', nativeName: 'Nederlands (Nederland)', direction: 'ltr', enabled: true },
|
|
589
|
+
{ code: 'nl-BE', name: 'Dutch (Belgium)', nativeName: 'Nederlands (België)', direction: 'ltr', enabled: true },
|
|
590
|
+
{ code: 'pl', name: 'Polish', nativeName: 'Polski', direction: 'ltr', enabled: true },
|
|
591
|
+
{ code: 'pl-PL', name: 'Polish (Poland)', nativeName: 'Polski (Polska)', direction: 'ltr', enabled: true },
|
|
592
|
+
{ code: 'tr', name: 'Turkish', nativeName: 'Türkçe', direction: 'ltr', enabled: true },
|
|
593
|
+
{ code: 'tr-TR', name: 'Turkish (Turkey)', nativeName: 'Türkçe (Türkiye)', direction: 'ltr', enabled: true },
|
|
594
|
+
{ code: 'sv', name: 'Swedish', nativeName: 'Svenska', direction: 'ltr', enabled: true },
|
|
595
|
+
{ code: 'sv-SE', name: 'Swedish (Sweden)', nativeName: 'Svenska (Sverige)', direction: 'ltr', enabled: true },
|
|
596
|
+
{ code: 'da', name: 'Danish', nativeName: 'Dansk', direction: 'ltr', enabled: true },
|
|
597
|
+
{ code: 'da-DK', name: 'Danish (Denmark)', nativeName: 'Dansk (Danmark)', direction: 'ltr', enabled: true },
|
|
598
|
+
{ code: 'no', name: 'Norwegian', nativeName: 'Norsk', direction: 'ltr', enabled: true },
|
|
599
|
+
{ code: 'nb-NO', name: 'Norwegian Bokmål', nativeName: 'Norsk Bokmål', direction: 'ltr', enabled: true },
|
|
600
|
+
{ code: 'fi', name: 'Finnish', nativeName: 'Suomi', direction: 'ltr', enabled: true },
|
|
601
|
+
{ code: 'fi-FI', name: 'Finnish (Finland)', nativeName: 'Suomi (Suomi)', direction: 'ltr', enabled: true },
|
|
602
|
+
{ code: 'cs', name: 'Czech', nativeName: 'Čeština', direction: 'ltr', enabled: true },
|
|
603
|
+
{ code: 'cs-CZ', name: 'Czech (Czech Republic)', nativeName: 'Čeština (Česká republika)', direction: 'ltr', enabled: true },
|
|
604
|
+
{ code: 'hu', name: 'Hungarian', nativeName: 'Magyar', direction: 'ltr', enabled: true },
|
|
605
|
+
{ code: 'hu-HU', name: 'Hungarian (Hungary)', nativeName: 'Magyar (Magyarország)', direction: 'ltr', enabled: true },
|
|
606
|
+
{ code: 'el', name: 'Greek', nativeName: 'Ελληνικά', direction: 'ltr', enabled: true },
|
|
607
|
+
{ code: 'el-GR', name: 'Greek (Greece)', nativeName: 'Ελληνικά (Ελλάδα)', direction: 'ltr', enabled: true },
|
|
608
|
+
];
|
|
609
|
+
/**
|
|
610
|
+
* Get locale info by code
|
|
611
|
+
*/
|
|
612
|
+
function getLocaleInfo(code) {
|
|
613
|
+
return AVAILABLE_LOCALES.find(locale => locale.code === code);
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Check if a locale code is supported
|
|
617
|
+
*/
|
|
618
|
+
function isLocaleSupported(code) {
|
|
619
|
+
return AVAILABLE_LOCALES.some(locale => locale.code === code);
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Get fallback locale code
|
|
623
|
+
* For example: en-US -> en, zh-CN -> zh
|
|
624
|
+
*/
|
|
625
|
+
function getFallbackLocale(code) {
|
|
626
|
+
// Already a base locale
|
|
627
|
+
if (!code.includes('-')) {
|
|
628
|
+
return undefined;
|
|
629
|
+
}
|
|
630
|
+
// Extract base language code
|
|
631
|
+
const baseCode = code.split('-')[0];
|
|
632
|
+
return isLocaleSupported(baseCode) ? baseCode : undefined;
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Resolve locale with fallback
|
|
636
|
+
* Tries the exact locale, then fallback to base language
|
|
637
|
+
*/
|
|
638
|
+
function resolveLocale(code) {
|
|
639
|
+
// Exact match
|
|
640
|
+
if (isLocaleSupported(code)) {
|
|
641
|
+
return code;
|
|
642
|
+
}
|
|
643
|
+
// Try fallback
|
|
644
|
+
const fallback = getFallbackLocale(code);
|
|
645
|
+
if (fallback) {
|
|
646
|
+
return fallback;
|
|
647
|
+
}
|
|
648
|
+
// Default to English
|
|
649
|
+
return 'en';
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const KotoContext = createContext(null);
|
|
653
|
+
const LOCALE_STORAGE_KEY = 'koto-selected-locale';
|
|
654
|
+
function KotoProvider({ children, apiKey, projectId, defaultLocale, apiUrl = 'https://api.koto.dev/v1/translations' }) {
|
|
655
|
+
// Initialize locale from localStorage or use defaultLocale
|
|
656
|
+
const getInitialLocale = () => {
|
|
657
|
+
if (typeof window !== 'undefined') {
|
|
658
|
+
const savedLocale = localStorage.getItem(LOCALE_STORAGE_KEY);
|
|
659
|
+
if (savedLocale) {
|
|
660
|
+
return savedLocale;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return defaultLocale;
|
|
664
|
+
};
|
|
665
|
+
const [translations, setTranslations] = useState({});
|
|
666
|
+
const [locale, setLocale] = useState(getInitialLocale());
|
|
667
|
+
const [loading, setLoading] = useState(true);
|
|
668
|
+
const [error, setError] = useState(null);
|
|
669
|
+
// Use hardcoded locales that match the database
|
|
670
|
+
const availableLocales = AVAILABLE_LOCALES;
|
|
671
|
+
// Fetch translations from API
|
|
672
|
+
const fetchTranslations = useCallback(async (currentLocale) => {
|
|
673
|
+
try {
|
|
674
|
+
const response = await fetch(`${apiUrl}?locale=${currentLocale}&projectId=${projectId}`, {
|
|
675
|
+
headers: {
|
|
676
|
+
'x-api-key': apiKey,
|
|
677
|
+
'Content-Type': 'application/json',
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
if (!response.ok) {
|
|
681
|
+
throw new Error(`Failed to fetch translations: ${response.statusText}`);
|
|
682
|
+
}
|
|
683
|
+
const data = await response.json();
|
|
684
|
+
// Let storage handle the response format dynamically
|
|
685
|
+
return await storage.processApiResponse(currentLocale, data);
|
|
686
|
+
}
|
|
687
|
+
catch (err) {
|
|
688
|
+
throw err instanceof Error ? err : new Error('Failed to fetch translations');
|
|
689
|
+
}
|
|
690
|
+
}, [apiKey, projectId, apiUrl]);
|
|
691
|
+
// Load translations (from cache or API)
|
|
692
|
+
const loadTranslations = useCallback(async (currentLocale) => {
|
|
693
|
+
setLoading(true);
|
|
694
|
+
setError(null);
|
|
695
|
+
try {
|
|
696
|
+
// Try to get from IndexedDB first
|
|
697
|
+
const cached = await storage.getTranslations(currentLocale);
|
|
698
|
+
if (cached) {
|
|
699
|
+
setTranslations(cached.translations);
|
|
700
|
+
setLoading(false);
|
|
701
|
+
// Fetch fresh data in background
|
|
702
|
+
fetchTranslations(currentLocale)
|
|
703
|
+
.then((freshTranslations) => {
|
|
704
|
+
setTranslations(freshTranslations);
|
|
705
|
+
})
|
|
706
|
+
.catch(console.error);
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
// No cache, fetch from API
|
|
710
|
+
const freshTranslations = await fetchTranslations(currentLocale);
|
|
711
|
+
setTranslations(freshTranslations);
|
|
712
|
+
setLoading(false);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
catch (err) {
|
|
716
|
+
setError(err instanceof Error ? err : new Error('Failed to load translations'));
|
|
717
|
+
setLoading(false);
|
|
718
|
+
}
|
|
719
|
+
}, [fetchTranslations]);
|
|
720
|
+
// Translation function
|
|
721
|
+
const t = useCallback((key, fallback) => {
|
|
722
|
+
const translation = getNestedTranslation(translations, key);
|
|
723
|
+
return translation || fallback || key;
|
|
724
|
+
}, [translations]);
|
|
725
|
+
// Function to change locale
|
|
726
|
+
const changeLocale = useCallback((newLocale) => {
|
|
727
|
+
if (newLocale !== locale) {
|
|
728
|
+
setLocale(newLocale);
|
|
729
|
+
// Save to localStorage
|
|
730
|
+
if (typeof window !== 'undefined') {
|
|
731
|
+
localStorage.setItem(LOCALE_STORAGE_KEY, newLocale);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}, [locale]);
|
|
735
|
+
// Get available locales (returns hardcoded list)
|
|
736
|
+
const getAvailableLocales = useCallback(() => {
|
|
737
|
+
return availableLocales;
|
|
738
|
+
}, [availableLocales]);
|
|
739
|
+
// Load translations on mount and locale change
|
|
740
|
+
useEffect(() => {
|
|
741
|
+
loadTranslations(locale);
|
|
742
|
+
}, [locale, loadTranslations]);
|
|
743
|
+
// Context value
|
|
744
|
+
const value = useMemo(() => ({
|
|
745
|
+
translations,
|
|
746
|
+
locale,
|
|
747
|
+
loading,
|
|
748
|
+
error,
|
|
749
|
+
t,
|
|
750
|
+
setLocale: changeLocale,
|
|
751
|
+
availableLocales,
|
|
752
|
+
getAvailableLocales,
|
|
753
|
+
}), [translations, locale, loading, error, t, changeLocale, availableLocales, getAvailableLocales]);
|
|
754
|
+
return (React.createElement(KotoContext.Provider, { value: value }, children));
|
|
755
|
+
}
|
|
756
|
+
// Hook to use Koto context
|
|
757
|
+
function useKoto() {
|
|
758
|
+
const context = useContext(KotoContext);
|
|
759
|
+
if (!context) {
|
|
760
|
+
throw new Error('useKoto must be used within a KotoProvider');
|
|
761
|
+
}
|
|
762
|
+
return context;
|
|
763
|
+
}
|
|
764
|
+
// Hook to use translation function
|
|
765
|
+
function useTranslation() {
|
|
766
|
+
const { t, locale, loading, setLocale, availableLocales, getAvailableLocales } = useKoto();
|
|
767
|
+
return { t, locale, loading, setLocale, availableLocales, getAvailableLocales };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Higher-Order Component for class components
|
|
772
|
+
* Wraps a component and injects translation props
|
|
773
|
+
*/
|
|
774
|
+
function withTranslation(Component) {
|
|
775
|
+
const ComponentWithTranslation = (props) => {
|
|
776
|
+
const { t, locale, loading, setLocale, translations, error } = useKoto();
|
|
777
|
+
return (React.createElement(Component, { ...props, t: t, locale: locale, loading: loading, setLocale: setLocale, translations: translations, error: error }));
|
|
778
|
+
};
|
|
779
|
+
ComponentWithTranslation.displayName = `withTranslation(${Component.displayName || Component.name || 'Component'})`;
|
|
780
|
+
return ComponentWithTranslation;
|
|
781
|
+
}
|
|
782
|
+
function Translation({ children }) {
|
|
783
|
+
const kotoContext = useKoto();
|
|
784
|
+
return React.createElement(React.Fragment, null, children(kotoContext));
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Consumer component for direct context access
|
|
788
|
+
* Useful for class components that need conditional rendering
|
|
789
|
+
*/
|
|
790
|
+
class KotoConsumer extends React.Component {
|
|
791
|
+
render() {
|
|
792
|
+
return React.createElement(Translation, null, this.props.children);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Global cache for translations
|
|
797
|
+
let globalTranslations = {};
|
|
798
|
+
let globalLocale = 'en';
|
|
799
|
+
let isInitialized = false;
|
|
800
|
+
/**
|
|
801
|
+
* Initialize the translation function with a locale
|
|
802
|
+
* This should be called once when the app starts
|
|
803
|
+
*/
|
|
804
|
+
async function initTranslations(locale) {
|
|
805
|
+
globalLocale = locale;
|
|
806
|
+
// Try to load from IndexedDB
|
|
807
|
+
const cached = await storage.getTranslations(locale);
|
|
808
|
+
if (cached) {
|
|
809
|
+
globalTranslations = cached.translations;
|
|
810
|
+
isInitialized = true;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Set translations directly (useful when fetched from API)
|
|
815
|
+
*/
|
|
816
|
+
function setTranslations(translations, locale) {
|
|
817
|
+
globalTranslations = translations;
|
|
818
|
+
globalLocale = locale;
|
|
819
|
+
isInitialized = true;
|
|
820
|
+
// Store in IndexedDB for future use
|
|
821
|
+
storage.setTranslations(locale, translations);
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Get current locale
|
|
825
|
+
*/
|
|
826
|
+
function getLocale() {
|
|
827
|
+
return globalLocale;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Translation function
|
|
831
|
+
* @param key - The translation key (e.g., 'checkout.payment.title')
|
|
832
|
+
* @param fallback - Optional fallback value if translation not found
|
|
833
|
+
* @returns The translated string or the key if not found
|
|
834
|
+
*/
|
|
835
|
+
function t(key, fallback) {
|
|
836
|
+
if (!isInitialized) {
|
|
837
|
+
console.warn('Translations not initialized. Call initTranslations() first.');
|
|
838
|
+
return fallback || key;
|
|
839
|
+
}
|
|
840
|
+
const translation = getNestedTranslation(globalTranslations, key);
|
|
841
|
+
return translation || fallback || key;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Translation function with interpolation
|
|
845
|
+
* @param key - The translation key
|
|
846
|
+
* @param params - Object with values to interpolate
|
|
847
|
+
* @param fallback - Optional fallback value
|
|
848
|
+
* @returns The translated and interpolated string
|
|
849
|
+
*
|
|
850
|
+
* @example
|
|
851
|
+
* // Translation: "Hello, {{name}}!"
|
|
852
|
+
* ti('greeting', { name: 'John' }) // "Hello, John!"
|
|
853
|
+
*/
|
|
854
|
+
function ti(key, params, fallback) {
|
|
855
|
+
let translation = t(key, fallback);
|
|
856
|
+
// Replace placeholders with values
|
|
857
|
+
Object.entries(params).forEach(([paramKey, value]) => {
|
|
858
|
+
const placeholder = new RegExp(`{{\\s*${paramKey}\\s*}}`, 'g');
|
|
859
|
+
translation = translation.replace(placeholder, String(value));
|
|
860
|
+
});
|
|
861
|
+
return translation;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Pluralization helper
|
|
865
|
+
* @param key - The base translation key
|
|
866
|
+
* @param count - The count for pluralization
|
|
867
|
+
* @param params - Optional parameters for interpolation
|
|
868
|
+
* @returns The pluralized translation
|
|
869
|
+
*
|
|
870
|
+
* @example
|
|
871
|
+
* // Translations:
|
|
872
|
+
* // "items.zero": "No items"
|
|
873
|
+
* // "items.one": "One item"
|
|
874
|
+
* // "items.other": "{{count}} items"
|
|
875
|
+
* tp('items', 0) // "No items"
|
|
876
|
+
* tp('items', 1) // "One item"
|
|
877
|
+
* tp('items', 5) // "5 items"
|
|
878
|
+
*/
|
|
879
|
+
function tp(key, count, params) {
|
|
880
|
+
let pluralKey = key;
|
|
881
|
+
if (count === 0) {
|
|
882
|
+
pluralKey = `${key}.zero`;
|
|
883
|
+
}
|
|
884
|
+
else if (count === 1) {
|
|
885
|
+
pluralKey = `${key}.one`;
|
|
886
|
+
}
|
|
887
|
+
else {
|
|
888
|
+
pluralKey = `${key}.other`;
|
|
889
|
+
}
|
|
890
|
+
// Try the plural key first, fall back to base key
|
|
891
|
+
let translation = t(pluralKey);
|
|
892
|
+
if (translation === pluralKey) {
|
|
893
|
+
translation = t(key);
|
|
894
|
+
}
|
|
895
|
+
// Apply interpolation with count included
|
|
896
|
+
const allParams = { count, ...params };
|
|
897
|
+
return ti(translation, allParams);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Provider and hooks
|
|
901
|
+
|
|
902
|
+
export { AVAILABLE_LOCALES, KotoConsumer as HermesConsumer, KotoProvider as HermesProvider, KotoConsumer, KotoProvider, Translation, t as default, flattenTranslations, getFallbackLocale, getLocale, getLocaleInfo, getNestedTranslation, initTranslations, isLocaleSupported, resolveLocale, setTranslations, storage, t, ti, tp, unflattenTranslations, useKoto as useHermes, useKoto, useTranslation, withTranslation };
|
|
903
|
+
//# sourceMappingURL=index.esm.js.map
|