@universal-i18n/core 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/dist/index.d.mts +347 -0
- package/dist/index.d.ts +347 -0
- package/dist/index.js +804 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +752 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +35 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var TranslationError = class extends Error {
|
|
3
|
+
constructor(message, sourceText, targetLocale, cause) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.sourceText = sourceText;
|
|
6
|
+
this.targetLocale = targetLocale;
|
|
7
|
+
this.cause = cause;
|
|
8
|
+
this.name = "TranslationError";
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
var CacheError = class extends Error {
|
|
12
|
+
constructor(message, backend, cause) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.backend = backend;
|
|
15
|
+
this.cause = cause;
|
|
16
|
+
this.name = "CacheError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var ConfigError = class extends Error {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "ConfigError";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var RateLimitError = class extends Error {
|
|
26
|
+
constructor(message, retryAfterMs) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.retryAfterMs = retryAfterMs;
|
|
29
|
+
this.name = "RateLimitError";
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// src/cache/memory-cache.ts
|
|
34
|
+
var MemoryCache = class {
|
|
35
|
+
cache;
|
|
36
|
+
maxEntries;
|
|
37
|
+
hits = 0;
|
|
38
|
+
misses = 0;
|
|
39
|
+
constructor(maxEntries = 1e3) {
|
|
40
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
41
|
+
this.maxEntries = maxEntries;
|
|
42
|
+
}
|
|
43
|
+
async get(key) {
|
|
44
|
+
const entry = this.cache.get(key);
|
|
45
|
+
if (!entry) {
|
|
46
|
+
this.misses++;
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (entry.ttl !== void 0 && Date.now() - entry.createdAt > entry.ttl * 1e3) {
|
|
50
|
+
this.cache.delete(key);
|
|
51
|
+
this.misses++;
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
this.cache.delete(key);
|
|
55
|
+
this.cache.set(key, entry);
|
|
56
|
+
this.hits++;
|
|
57
|
+
return entry.value;
|
|
58
|
+
}
|
|
59
|
+
async set(key, value, ttl) {
|
|
60
|
+
if (this.cache.has(key)) {
|
|
61
|
+
this.cache.delete(key);
|
|
62
|
+
}
|
|
63
|
+
if (this.cache.size >= this.maxEntries) {
|
|
64
|
+
const firstKey = this.cache.keys().next().value;
|
|
65
|
+
if (firstKey !== void 0) {
|
|
66
|
+
this.cache.delete(firstKey);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
this.cache.set(key, {
|
|
70
|
+
value,
|
|
71
|
+
createdAt: Date.now(),
|
|
72
|
+
ttl
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async delete(key) {
|
|
76
|
+
this.cache.delete(key);
|
|
77
|
+
}
|
|
78
|
+
async clear(pattern) {
|
|
79
|
+
if (!pattern) {
|
|
80
|
+
this.cache.clear();
|
|
81
|
+
this.hits = 0;
|
|
82
|
+
this.misses = 0;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const prefix = pattern.replace(/\*$/, "");
|
|
86
|
+
for (const key of this.cache.keys()) {
|
|
87
|
+
if (key.startsWith(prefix)) {
|
|
88
|
+
this.cache.delete(key);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async stats() {
|
|
93
|
+
const total = this.hits + this.misses;
|
|
94
|
+
const entries = Array.from(this.cache.values());
|
|
95
|
+
let oldestEntry;
|
|
96
|
+
let newestEntry;
|
|
97
|
+
if (entries.length > 0) {
|
|
98
|
+
const sorted = entries.sort((a, b) => a.createdAt - b.createdAt);
|
|
99
|
+
oldestEntry = new Date(sorted[0].createdAt).toISOString();
|
|
100
|
+
newestEntry = new Date(sorted[sorted.length - 1].createdAt).toISOString();
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
totalEntries: this.cache.size,
|
|
104
|
+
hits: this.hits,
|
|
105
|
+
misses: this.misses,
|
|
106
|
+
hitRate: total > 0 ? this.hits / total : 0,
|
|
107
|
+
missRate: total > 0 ? this.misses / total : 0,
|
|
108
|
+
oldestEntry,
|
|
109
|
+
newestEntry
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// src/cache/fs-cache.ts
|
|
115
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, unlinkSync } from "fs";
|
|
116
|
+
import { join, dirname } from "path";
|
|
117
|
+
var FileSystemCache = class {
|
|
118
|
+
dir;
|
|
119
|
+
hits = 0;
|
|
120
|
+
misses = 0;
|
|
121
|
+
defaultTtl;
|
|
122
|
+
constructor(dir = ".cache/universal-i18n", defaultTtl = 604800) {
|
|
123
|
+
this.dir = dir;
|
|
124
|
+
this.defaultTtl = defaultTtl;
|
|
125
|
+
this.ensureDir(this.dir);
|
|
126
|
+
}
|
|
127
|
+
ensureDir(dirPath) {
|
|
128
|
+
if (!existsSync(dirPath)) {
|
|
129
|
+
mkdirSync(dirPath, { recursive: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
getFilePath(key) {
|
|
133
|
+
const parts = key.split(":");
|
|
134
|
+
const namespace = parts[3] || "default";
|
|
135
|
+
const locale = parts[2] || "unknown";
|
|
136
|
+
const entryKey = parts[4] || key;
|
|
137
|
+
const localeDir = join(this.dir, locale);
|
|
138
|
+
this.ensureDir(localeDir);
|
|
139
|
+
return {
|
|
140
|
+
filePath: join(localeDir, `${namespace}.json`),
|
|
141
|
+
entryKey
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
readCacheFile(filePath) {
|
|
145
|
+
if (!existsSync(filePath)) {
|
|
146
|
+
return { version: "1.0", entries: {} };
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
150
|
+
return JSON.parse(raw);
|
|
151
|
+
} catch {
|
|
152
|
+
return { version: "1.0", entries: {} };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
writeCacheFile(filePath, data) {
|
|
156
|
+
const tmpPath = `${filePath}.tmp`;
|
|
157
|
+
this.ensureDir(dirname(filePath));
|
|
158
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf-8");
|
|
159
|
+
renameSync(tmpPath, filePath);
|
|
160
|
+
}
|
|
161
|
+
async get(key) {
|
|
162
|
+
const { filePath, entryKey } = this.getFilePath(key);
|
|
163
|
+
const cacheFile = this.readCacheFile(filePath);
|
|
164
|
+
const entry = cacheFile.entries[entryKey];
|
|
165
|
+
if (!entry) {
|
|
166
|
+
this.misses++;
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const ttl = entry.ttl ?? this.defaultTtl;
|
|
170
|
+
const age = (Date.now() - new Date(entry.translatedAt).getTime()) / 1e3;
|
|
171
|
+
if (age > ttl) {
|
|
172
|
+
delete cacheFile.entries[entryKey];
|
|
173
|
+
this.writeCacheFile(filePath, cacheFile);
|
|
174
|
+
this.misses++;
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
this.hits++;
|
|
178
|
+
return entry.value;
|
|
179
|
+
}
|
|
180
|
+
async set(key, value, ttl) {
|
|
181
|
+
const { filePath, entryKey } = this.getFilePath(key);
|
|
182
|
+
const cacheFile = this.readCacheFile(filePath);
|
|
183
|
+
cacheFile.entries[entryKey] = {
|
|
184
|
+
value,
|
|
185
|
+
translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
186
|
+
ttl: ttl ?? this.defaultTtl
|
|
187
|
+
};
|
|
188
|
+
this.writeCacheFile(filePath, cacheFile);
|
|
189
|
+
}
|
|
190
|
+
async delete(key) {
|
|
191
|
+
const { filePath, entryKey } = this.getFilePath(key);
|
|
192
|
+
const cacheFile = this.readCacheFile(filePath);
|
|
193
|
+
if (entryKey in cacheFile.entries) {
|
|
194
|
+
delete cacheFile.entries[entryKey];
|
|
195
|
+
this.writeCacheFile(filePath, cacheFile);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async clear(pattern) {
|
|
199
|
+
if (!pattern) {
|
|
200
|
+
this.clearDirectory(this.dir);
|
|
201
|
+
this.hits = 0;
|
|
202
|
+
this.misses = 0;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const parts = pattern.split(":");
|
|
206
|
+
const locale = parts[2];
|
|
207
|
+
if (locale) {
|
|
208
|
+
const localeDir = join(this.dir, locale);
|
|
209
|
+
if (existsSync(localeDir)) {
|
|
210
|
+
this.clearDirectory(localeDir);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
clearDirectory(dirPath) {
|
|
215
|
+
if (!existsSync(dirPath)) return;
|
|
216
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
const fullPath = join(dirPath, entry.name);
|
|
219
|
+
if (entry.isDirectory()) {
|
|
220
|
+
this.clearDirectory(fullPath);
|
|
221
|
+
} else {
|
|
222
|
+
unlinkSync(fullPath);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async stats() {
|
|
227
|
+
const total = this.hits + this.misses;
|
|
228
|
+
let totalEntries = 0;
|
|
229
|
+
let sizeBytes = 0;
|
|
230
|
+
let oldest;
|
|
231
|
+
let newest;
|
|
232
|
+
this.scanDirectory(this.dir, (filePath) => {
|
|
233
|
+
try {
|
|
234
|
+
const stat = statSync(filePath);
|
|
235
|
+
sizeBytes += stat.size;
|
|
236
|
+
const cacheFile = this.readCacheFile(filePath);
|
|
237
|
+
for (const entry of Object.values(cacheFile.entries)) {
|
|
238
|
+
totalEntries++;
|
|
239
|
+
const date = new Date(entry.translatedAt);
|
|
240
|
+
if (!oldest || date < oldest) oldest = date;
|
|
241
|
+
if (!newest || date > newest) newest = date;
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
return {
|
|
247
|
+
totalEntries,
|
|
248
|
+
hits: this.hits,
|
|
249
|
+
misses: this.misses,
|
|
250
|
+
hitRate: total > 0 ? this.hits / total : 0,
|
|
251
|
+
missRate: total > 0 ? this.misses / total : 0,
|
|
252
|
+
sizeBytes,
|
|
253
|
+
oldestEntry: oldest?.toISOString(),
|
|
254
|
+
newestEntry: newest?.toISOString()
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
scanDirectory(dirPath, callback) {
|
|
258
|
+
if (!existsSync(dirPath)) return;
|
|
259
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
260
|
+
for (const entry of entries) {
|
|
261
|
+
const fullPath = join(dirPath, entry.name);
|
|
262
|
+
if (entry.isDirectory()) {
|
|
263
|
+
this.scanDirectory(fullPath, callback);
|
|
264
|
+
} else if (entry.name.endsWith(".json")) {
|
|
265
|
+
callback(fullPath);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// src/cache/redis-cache.ts
|
|
272
|
+
var RedisCache = class {
|
|
273
|
+
client;
|
|
274
|
+
defaultTtl;
|
|
275
|
+
hits = 0;
|
|
276
|
+
misses = 0;
|
|
277
|
+
connected = false;
|
|
278
|
+
constructor(redisUrl, defaultTtl = 604800) {
|
|
279
|
+
this.defaultTtl = defaultTtl;
|
|
280
|
+
this.initClient(redisUrl);
|
|
281
|
+
}
|
|
282
|
+
async initClient(redisUrl) {
|
|
283
|
+
try {
|
|
284
|
+
const Redis = await this.loadRedis();
|
|
285
|
+
const url = redisUrl || process.env.REDIS_URL || "redis://localhost:6379";
|
|
286
|
+
this.client = new Redis(url, {
|
|
287
|
+
maxRetriesPerRequest: 3,
|
|
288
|
+
retryStrategy(times) {
|
|
289
|
+
if (times > 3) return null;
|
|
290
|
+
return Math.min(times * 200, 2e3);
|
|
291
|
+
},
|
|
292
|
+
lazyConnect: true
|
|
293
|
+
});
|
|
294
|
+
await this.client.connect();
|
|
295
|
+
this.connected = true;
|
|
296
|
+
} catch (error) {
|
|
297
|
+
console.warn("[ui18n:warn] Redis connection failed. Cache operations will be no-ops.", error);
|
|
298
|
+
this.connected = false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async loadRedis() {
|
|
302
|
+
try {
|
|
303
|
+
const mod = await import("ioredis");
|
|
304
|
+
return mod.default || mod;
|
|
305
|
+
} catch {
|
|
306
|
+
throw new Error(
|
|
307
|
+
"ioredis is required for Redis cache backend. Install it with: npm install ioredis"
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async get(key) {
|
|
312
|
+
if (!this.connected || !this.client) {
|
|
313
|
+
this.misses++;
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
const value = await this.client.get(key);
|
|
318
|
+
if (value === null) {
|
|
319
|
+
this.misses++;
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
this.hits++;
|
|
323
|
+
return value;
|
|
324
|
+
} catch {
|
|
325
|
+
this.misses++;
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async set(key, value, ttl) {
|
|
330
|
+
if (!this.connected || !this.client) return;
|
|
331
|
+
try {
|
|
332
|
+
const effectiveTtl = ttl ?? this.defaultTtl;
|
|
333
|
+
await this.client.set(key, value, "EX", effectiveTtl);
|
|
334
|
+
} catch {
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
async delete(key) {
|
|
338
|
+
if (!this.connected || !this.client) return;
|
|
339
|
+
try {
|
|
340
|
+
await this.client.del(key);
|
|
341
|
+
} catch {
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
async clear(pattern) {
|
|
345
|
+
if (!this.connected || !this.client) return;
|
|
346
|
+
try {
|
|
347
|
+
if (!pattern) {
|
|
348
|
+
const keys = await this.client.keys("ui18n:*");
|
|
349
|
+
if (keys.length > 0) {
|
|
350
|
+
await this.client.del(...keys);
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
const keys = await this.client.keys(pattern);
|
|
354
|
+
if (keys.length > 0) {
|
|
355
|
+
await this.client.del(...keys);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
this.hits = 0;
|
|
359
|
+
this.misses = 0;
|
|
360
|
+
} catch {
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
async stats() {
|
|
364
|
+
const total = this.hits + this.misses;
|
|
365
|
+
let totalEntries = 0;
|
|
366
|
+
if (this.connected && this.client) {
|
|
367
|
+
try {
|
|
368
|
+
const keys = await this.client.keys("ui18n:*");
|
|
369
|
+
totalEntries = keys.length;
|
|
370
|
+
} catch {
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
totalEntries,
|
|
375
|
+
hits: this.hits,
|
|
376
|
+
misses: this.misses,
|
|
377
|
+
hitRate: total > 0 ? this.hits / total : 0,
|
|
378
|
+
missRate: total > 0 ? this.misses / total : 0
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
async disconnect() {
|
|
382
|
+
if (this.connected && this.client) {
|
|
383
|
+
await this.client.quit();
|
|
384
|
+
this.connected = false;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// src/cache/index.ts
|
|
390
|
+
function createCache(config) {
|
|
391
|
+
const backend = config?.backend ?? "fs";
|
|
392
|
+
const ttl = config?.ttl ?? 604800;
|
|
393
|
+
switch (backend) {
|
|
394
|
+
case "memory":
|
|
395
|
+
return new MemoryCache(config?.memoryMaxEntries ?? 1e3);
|
|
396
|
+
case "redis":
|
|
397
|
+
return new RedisCache(config?.redisUrl, ttl);
|
|
398
|
+
case "fs":
|
|
399
|
+
default: {
|
|
400
|
+
const dir = config?.fsDir ?? process.env.UI18N_CACHE_DIR ?? ".cache/universal-i18n";
|
|
401
|
+
return new FileSystemCache(dir, ttl);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/utils.ts
|
|
407
|
+
import { createHash } from "crypto";
|
|
408
|
+
function generateCacheKey(sourceLocale, targetLocale, namespace, sourceText) {
|
|
409
|
+
const hash = createHash("sha256").update(sourceText).digest("hex").slice(0, 16);
|
|
410
|
+
return `ui18n:${sourceLocale}:${targetLocale}:${namespace}:${hash}`;
|
|
411
|
+
}
|
|
412
|
+
function sha256(text) {
|
|
413
|
+
return createHash("sha256").update(text).digest("hex");
|
|
414
|
+
}
|
|
415
|
+
function sleep(ms) {
|
|
416
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
417
|
+
}
|
|
418
|
+
function exponentialBackoff(attempt) {
|
|
419
|
+
const baseDelay = 1e3;
|
|
420
|
+
const delay = baseDelay * Math.pow(2, attempt);
|
|
421
|
+
const jitter = delay * 0.25 * (Math.random() * 2 - 1);
|
|
422
|
+
return Math.round(delay + jitter);
|
|
423
|
+
}
|
|
424
|
+
var LOG_LEVELS = {
|
|
425
|
+
debug: 0,
|
|
426
|
+
info: 1,
|
|
427
|
+
warn: 2,
|
|
428
|
+
error: 3
|
|
429
|
+
};
|
|
430
|
+
function createLogger(level) {
|
|
431
|
+
const envLevel = typeof process !== "undefined" && process.env?.UI18N_LOG_LEVEL;
|
|
432
|
+
const currentLevel = LOG_LEVELS[level ?? envLevel ?? "info"];
|
|
433
|
+
const shouldLog = (msgLevel) => LOG_LEVELS[msgLevel] >= currentLevel;
|
|
434
|
+
return {
|
|
435
|
+
debug(message, ...args) {
|
|
436
|
+
if (shouldLog("debug")) console.debug(`[ui18n:debug] ${message}`, ...args);
|
|
437
|
+
},
|
|
438
|
+
info(message, ...args) {
|
|
439
|
+
if (shouldLog("info")) console.info(`[ui18n:info] ${message}`, ...args);
|
|
440
|
+
},
|
|
441
|
+
warn(message, ...args) {
|
|
442
|
+
if (shouldLog("warn")) console.warn(`[ui18n:warn] ${message}`, ...args);
|
|
443
|
+
},
|
|
444
|
+
error(message, ...args) {
|
|
445
|
+
if (shouldLog("error")) console.error(`[ui18n:error] ${message}`, ...args);
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
function flattenObject(obj, prefix = "") {
|
|
450
|
+
const result = {};
|
|
451
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
452
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
453
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
454
|
+
Object.assign(result, flattenObject(value, fullKey));
|
|
455
|
+
} else {
|
|
456
|
+
result[fullKey] = String(value);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return result;
|
|
460
|
+
}
|
|
461
|
+
function unflattenObject(obj) {
|
|
462
|
+
const result = {};
|
|
463
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
464
|
+
const parts = key.split(".");
|
|
465
|
+
let current = result;
|
|
466
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
467
|
+
const part = parts[i];
|
|
468
|
+
if (!(part in current) || typeof current[part] !== "object") {
|
|
469
|
+
current[part] = {};
|
|
470
|
+
}
|
|
471
|
+
current = current[part];
|
|
472
|
+
}
|
|
473
|
+
current[parts[parts.length - 1]] = value;
|
|
474
|
+
}
|
|
475
|
+
return result;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// src/engine.ts
|
|
479
|
+
var UniversalI18nEngine = class {
|
|
480
|
+
cache;
|
|
481
|
+
logger;
|
|
482
|
+
apiKey;
|
|
483
|
+
sourceLocale;
|
|
484
|
+
targetLocales;
|
|
485
|
+
batchDelayMs;
|
|
486
|
+
maxRetries;
|
|
487
|
+
fallbackOnError;
|
|
488
|
+
provider;
|
|
489
|
+
providerApiKey;
|
|
490
|
+
model;
|
|
491
|
+
// Batching state
|
|
492
|
+
pendingBatch = [];
|
|
493
|
+
batchTimer = null;
|
|
494
|
+
// SDK engine instance (lazy-loaded)
|
|
495
|
+
sdkEngine = null;
|
|
496
|
+
constructor(config) {
|
|
497
|
+
this.apiKey = config.apiKey || process.env.LINGODOTDEV_API_KEY || "";
|
|
498
|
+
this.sourceLocale = config.sourceLocale ?? "en";
|
|
499
|
+
this.targetLocales = config.targetLocales;
|
|
500
|
+
this.batchDelayMs = config.batchDelayMs ?? 50;
|
|
501
|
+
this.maxRetries = config.maxRetries ?? 3;
|
|
502
|
+
this.fallbackOnError = config.fallbackOnError ?? true;
|
|
503
|
+
this.provider = config.provider ?? "lingo.dev";
|
|
504
|
+
this.providerApiKey = config.providerApiKey;
|
|
505
|
+
this.model = config.model;
|
|
506
|
+
this.cache = createCache(config.cache);
|
|
507
|
+
this.logger = createLogger();
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Initialize the lingo.dev SDK engine lazily.
|
|
511
|
+
*/
|
|
512
|
+
async getSDKEngine() {
|
|
513
|
+
if (this.sdkEngine) return this.sdkEngine;
|
|
514
|
+
try {
|
|
515
|
+
const sdk = await import("lingo.dev/sdk");
|
|
516
|
+
const LingoDotDevEngine = sdk.LingoDotDevEngine || sdk.default?.LingoDotDevEngine;
|
|
517
|
+
if (!LingoDotDevEngine) {
|
|
518
|
+
throw new Error("LingoDotDevEngine not found in lingo.dev package");
|
|
519
|
+
}
|
|
520
|
+
this.sdkEngine = new LingoDotDevEngine({
|
|
521
|
+
apiKey: this.apiKey
|
|
522
|
+
});
|
|
523
|
+
return this.sdkEngine;
|
|
524
|
+
} catch (error) {
|
|
525
|
+
this.logger.error("Failed to initialize lingo.dev SDK", error);
|
|
526
|
+
throw new TranslationError(
|
|
527
|
+
"Failed to initialize translation engine. Ensure lingo.dev is installed.",
|
|
528
|
+
void 0,
|
|
529
|
+
void 0,
|
|
530
|
+
error instanceof Error ? error : void 0
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Translate a single string with cache-first strategy.
|
|
536
|
+
*/
|
|
537
|
+
async translate(text, opts) {
|
|
538
|
+
const namespace = opts.namespace ?? "default";
|
|
539
|
+
const cacheKey = generateCacheKey(opts.sourceLocale, opts.targetLocale, namespace, text);
|
|
540
|
+
try {
|
|
541
|
+
const cached = await this.cache.get(cacheKey);
|
|
542
|
+
if (cached !== null) {
|
|
543
|
+
this.logger.debug(`Cache hit for key: ${cacheKey}`);
|
|
544
|
+
return cached;
|
|
545
|
+
}
|
|
546
|
+
} catch (error) {
|
|
547
|
+
this.logger.warn("Cache read failed, proceeding with API call", error);
|
|
548
|
+
}
|
|
549
|
+
const translated = await this.translateWithRetry(text, opts);
|
|
550
|
+
try {
|
|
551
|
+
await this.cache.set(cacheKey, translated);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
this.logger.warn("Cache write failed", error);
|
|
554
|
+
}
|
|
555
|
+
return translated;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Translate a nested object (all string values) with cache.
|
|
559
|
+
*/
|
|
560
|
+
async translateObject(obj, opts) {
|
|
561
|
+
const flat = flattenObject(obj);
|
|
562
|
+
const translatedFlat = {};
|
|
563
|
+
const entries = Object.entries(flat);
|
|
564
|
+
const translations = await Promise.all(
|
|
565
|
+
entries.map(([, value]) => this.translate(value, opts))
|
|
566
|
+
);
|
|
567
|
+
entries.forEach(([key], idx) => {
|
|
568
|
+
translatedFlat[key] = translations[idx];
|
|
569
|
+
});
|
|
570
|
+
return unflattenObject(translatedFlat);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Translate an HTML string.
|
|
574
|
+
*/
|
|
575
|
+
async translateHTML(html, opts) {
|
|
576
|
+
const namespace = opts.namespace ?? "html";
|
|
577
|
+
const cacheKey = generateCacheKey(opts.sourceLocale, opts.targetLocale, namespace, html);
|
|
578
|
+
try {
|
|
579
|
+
const cached = await this.cache.get(cacheKey);
|
|
580
|
+
if (cached !== null) return cached;
|
|
581
|
+
} catch {
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
const engine = await this.getSDKEngine();
|
|
585
|
+
const translated = await this.callWithRetry(async () => {
|
|
586
|
+
return await engine.translateHtml(html, {
|
|
587
|
+
sourceLocale: opts.sourceLocale,
|
|
588
|
+
targetLocale: opts.targetLocale
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
await this.cache.set(cacheKey, translated).catch(() => {
|
|
592
|
+
});
|
|
593
|
+
return translated;
|
|
594
|
+
} catch (error) {
|
|
595
|
+
if (this.fallbackOnError) {
|
|
596
|
+
this.logger.warn("HTML translation failed, returning original", error);
|
|
597
|
+
return html;
|
|
598
|
+
}
|
|
599
|
+
throw error;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Translate multiple items, leveraging batching and caching.
|
|
604
|
+
*/
|
|
605
|
+
async translateBatch(items, opts) {
|
|
606
|
+
const results = [];
|
|
607
|
+
for (const item of items) {
|
|
608
|
+
const namespace = opts.namespace ?? "default";
|
|
609
|
+
const cacheKey = generateCacheKey(opts.sourceLocale, opts.targetLocale, namespace, item.text);
|
|
610
|
+
let cached = false;
|
|
611
|
+
let translated = item.text;
|
|
612
|
+
try {
|
|
613
|
+
const cachedValue = await this.cache.get(cacheKey);
|
|
614
|
+
if (cachedValue !== null) {
|
|
615
|
+
translated = cachedValue;
|
|
616
|
+
cached = true;
|
|
617
|
+
}
|
|
618
|
+
} catch {
|
|
619
|
+
}
|
|
620
|
+
if (!cached) {
|
|
621
|
+
try {
|
|
622
|
+
translated = await this.translateWithRetry(item.text, opts);
|
|
623
|
+
await this.cache.set(cacheKey, translated).catch(() => {
|
|
624
|
+
});
|
|
625
|
+
} catch {
|
|
626
|
+
if (!this.fallbackOnError) {
|
|
627
|
+
throw new TranslationError(
|
|
628
|
+
`Batch translation failed for item: ${item.id ?? item.text.slice(0, 50)}`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
results.push({
|
|
634
|
+
id: item.id,
|
|
635
|
+
original: item.text,
|
|
636
|
+
translated,
|
|
637
|
+
cached
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
return results;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Get current cache statistics.
|
|
644
|
+
*/
|
|
645
|
+
getCacheStats() {
|
|
646
|
+
return this.cache.stats();
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Clear the cache, optionally for a specific locale.
|
|
650
|
+
*/
|
|
651
|
+
async clearCache(locale) {
|
|
652
|
+
if (locale) {
|
|
653
|
+
await this.cache.clear(`ui18n:*:${locale}:*`);
|
|
654
|
+
} else {
|
|
655
|
+
await this.cache.clear();
|
|
656
|
+
}
|
|
657
|
+
this.logger.info(`Cache cleared${locale ? ` for locale: ${locale}` : ""}`);
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Pre-populate cache by translating all provided keys for specified locales.
|
|
661
|
+
*/
|
|
662
|
+
async warmCache(keys, locales) {
|
|
663
|
+
this.logger.info(`Warming cache: ${keys.length} keys \xD7 ${locales.length} locales`);
|
|
664
|
+
for (const locale of locales) {
|
|
665
|
+
for (const key of keys) {
|
|
666
|
+
await this.translate(key, {
|
|
667
|
+
sourceLocale: this.sourceLocale,
|
|
668
|
+
targetLocale: locale,
|
|
669
|
+
namespace: "warm"
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
this.logger.info("Cache warming complete");
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Translate with automatic retry and exponential backoff.
|
|
677
|
+
*/
|
|
678
|
+
async translateWithRetry(text, opts) {
|
|
679
|
+
return this.callWithRetry(async () => {
|
|
680
|
+
const engine = await this.getSDKEngine();
|
|
681
|
+
const result = await engine.translateText(text, {
|
|
682
|
+
sourceLocale: opts.sourceLocale,
|
|
683
|
+
targetLocale: opts.targetLocale
|
|
684
|
+
});
|
|
685
|
+
return result;
|
|
686
|
+
}, text, opts.targetLocale);
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Generic retry wrapper with exponential backoff.
|
|
690
|
+
*/
|
|
691
|
+
async callWithRetry(fn, sourceText, targetLocale) {
|
|
692
|
+
let lastError;
|
|
693
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
694
|
+
try {
|
|
695
|
+
return await fn();
|
|
696
|
+
} catch (error) {
|
|
697
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
698
|
+
this.logger.warn(
|
|
699
|
+
`Translation attempt ${attempt + 1}/${this.maxRetries + 1} failed`,
|
|
700
|
+
lastError.message
|
|
701
|
+
);
|
|
702
|
+
if (lastError.message.includes("429") || lastError.message.toLowerCase().includes("rate limit")) {
|
|
703
|
+
const retryDelay = exponentialBackoff(attempt + 1);
|
|
704
|
+
this.logger.info(`Rate limited. Waiting ${retryDelay}ms before retry...`);
|
|
705
|
+
await sleep(retryDelay);
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (attempt < this.maxRetries) {
|
|
709
|
+
const delay = exponentialBackoff(attempt);
|
|
710
|
+
await sleep(delay);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if (this.fallbackOnError) {
|
|
715
|
+
this.logger.error("All translation attempts failed, returning original text");
|
|
716
|
+
return sourceText ?? "";
|
|
717
|
+
}
|
|
718
|
+
throw new TranslationError(
|
|
719
|
+
`Translation failed after ${this.maxRetries + 1} attempts`,
|
|
720
|
+
sourceText,
|
|
721
|
+
targetLocale,
|
|
722
|
+
lastError
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
// src/index.ts
|
|
728
|
+
function createEngine(config) {
|
|
729
|
+
return new UniversalI18nEngine(config);
|
|
730
|
+
}
|
|
731
|
+
function defineConfig(config) {
|
|
732
|
+
return config;
|
|
733
|
+
}
|
|
734
|
+
export {
|
|
735
|
+
CacheError,
|
|
736
|
+
ConfigError,
|
|
737
|
+
FileSystemCache,
|
|
738
|
+
MemoryCache,
|
|
739
|
+
RateLimitError,
|
|
740
|
+
RedisCache,
|
|
741
|
+
TranslationError,
|
|
742
|
+
UniversalI18nEngine,
|
|
743
|
+
createCache,
|
|
744
|
+
createEngine,
|
|
745
|
+
createLogger,
|
|
746
|
+
defineConfig,
|
|
747
|
+
flattenObject,
|
|
748
|
+
generateCacheKey,
|
|
749
|
+
sha256,
|
|
750
|
+
unflattenObject
|
|
751
|
+
};
|
|
752
|
+
//# sourceMappingURL=index.mjs.map
|