@djangocfg/llm 2.1.164

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +181 -0
  2. package/dist/index.cjs +1164 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +164 -0
  5. package/dist/index.d.ts +164 -0
  6. package/dist/index.mjs +1128 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/dist/providers/index.cjs +317 -0
  9. package/dist/providers/index.cjs.map +1 -0
  10. package/dist/providers/index.d.cts +30 -0
  11. package/dist/providers/index.d.ts +30 -0
  12. package/dist/providers/index.mjs +304 -0
  13. package/dist/providers/index.mjs.map +1 -0
  14. package/dist/sdkrouter-D8GMBmTi.d.ts +171 -0
  15. package/dist/sdkrouter-hlQlVd0v.d.cts +171 -0
  16. package/dist/text-utils-DoYqMIr6.d.ts +289 -0
  17. package/dist/text-utils-VXWN-8Oq.d.cts +289 -0
  18. package/dist/translator/index.cjs +794 -0
  19. package/dist/translator/index.cjs.map +1 -0
  20. package/dist/translator/index.d.cts +24 -0
  21. package/dist/translator/index.d.ts +24 -0
  22. package/dist/translator/index.mjs +769 -0
  23. package/dist/translator/index.mjs.map +1 -0
  24. package/dist/types-D6lazgm1.d.cts +59 -0
  25. package/dist/types-D6lazgm1.d.ts +59 -0
  26. package/package.json +82 -0
  27. package/src/client.ts +119 -0
  28. package/src/index.ts +70 -0
  29. package/src/providers/anthropic.ts +98 -0
  30. package/src/providers/base.ts +90 -0
  31. package/src/providers/index.ts +15 -0
  32. package/src/providers/openai.ts +73 -0
  33. package/src/providers/sdkrouter.ts +279 -0
  34. package/src/translator/cache.ts +237 -0
  35. package/src/translator/index.ts +55 -0
  36. package/src/translator/json-translator.ts +408 -0
  37. package/src/translator/prompts.ts +90 -0
  38. package/src/translator/text-utils.ts +148 -0
  39. package/src/translator/types.ts +112 -0
  40. package/src/translator/validator.ts +181 -0
  41. package/src/types.ts +85 -0
  42. package/src/utils/env.ts +67 -0
  43. package/src/utils/index.ts +2 -0
  44. package/src/utils/json.ts +44 -0
  45. package/src/utils/schema.ts +153 -0
@@ -0,0 +1,55 @@
1
+ /**
2
+ * JSON Translation module
3
+ *
4
+ * Features:
5
+ * - Smart text-level caching (memory + persistent)
6
+ * - Technical content detection (URLs, emails, paths)
7
+ * - Parallel multi-language translation
8
+ * - Partial JSON - only sends uncached texts to LLM
9
+ * - Language auto-detection
10
+ */
11
+
12
+ // Main translator
13
+ export { JsonTranslator, createTranslator } from './json-translator';
14
+
15
+ // Types
16
+ export type {
17
+ LanguageCode,
18
+ TranslateOptions,
19
+ TranslateResult,
20
+ BatchTranslateItem,
21
+ CacheStats,
22
+ } from './types';
23
+
24
+ export { LANGUAGE_NAMES } from './types';
25
+
26
+ // Cache
27
+ export { TranslationCache, createCache } from './cache';
28
+
29
+ // Validation
30
+ export {
31
+ validateTranslation,
32
+ validateJsonKeys,
33
+ validatePlaceholders,
34
+ type ValidationResult,
35
+ } from './validator';
36
+
37
+ // Text utilities
38
+ export {
39
+ isTechnicalContent,
40
+ extractPlaceholders,
41
+ extractTranslatableValues,
42
+ detectScript,
43
+ containsCJK,
44
+ containsCyrillic,
45
+ containsArabic,
46
+ validatePlaceholders as validateTextPlaceholders,
47
+ } from './text-utils';
48
+
49
+ // Prompts (for advanced usage)
50
+ export {
51
+ buildJsonTranslationPrompt,
52
+ buildTextTranslationPrompt,
53
+ buildRetryPrompt,
54
+ getLanguageName,
55
+ } from './prompts';
@@ -0,0 +1,408 @@
1
+ /**
2
+ * JSON Translator using LLM
3
+ *
4
+ * Smart text-level caching for efficiency:
5
+ * 1. Extract all translatable strings from JSON
6
+ * 2. Check cache for each string
7
+ * 3. Send only uncached strings to LLM
8
+ * 4. Cache new translations
9
+ * 5. Apply all translations to original structure
10
+ */
11
+
12
+ import type { LLMClient } from '../types';
13
+ import { extractJson } from '../utils/json';
14
+ import type { TranslateOptions, TranslateResult, LanguageCode } from './types';
15
+ import { buildJsonTranslationPrompt } from './prompts';
16
+ import { isTechnicalContent, detectScript } from './text-utils';
17
+ import { TranslationCache, createCache } from './cache';
18
+
19
+ /** Default model for translation - gpt-4o-mini is reliable and cost-effective */
20
+ const DEFAULT_TRANSLATION_MODEL = 'openai/gpt-4o-mini';
21
+
22
+ /**
23
+ * JSON Translator class with smart caching
24
+ */
25
+ export class JsonTranslator {
26
+ private cache: TranslationCache;
27
+ private defaultModel: string;
28
+
29
+ constructor(
30
+ private client: LLMClient,
31
+ cache?: TranslationCache,
32
+ defaultModel?: string
33
+ ) {
34
+ this.cache = cache ?? createCache();
35
+ this.defaultModel = defaultModel ?? DEFAULT_TRANSLATION_MODEL;
36
+ }
37
+
38
+ /**
39
+ * Detect language from text
40
+ */
41
+ detectLanguage(text: string): LanguageCode {
42
+ const script = detectScript(text);
43
+ switch (script) {
44
+ case 'cjk':
45
+ // Could be zh, ja, ko - default to zh
46
+ return 'zh';
47
+ case 'cyrillic':
48
+ return 'ru';
49
+ case 'arabic':
50
+ return 'ar';
51
+ default:
52
+ return 'en';
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Check if text needs translation
58
+ */
59
+ needsTranslation(
60
+ text: string,
61
+ sourceLang: string,
62
+ targetLang: string
63
+ ): boolean {
64
+ if (!text || !text.trim()) return false;
65
+ if (isTechnicalContent(text)) return false;
66
+ if (sourceLang === targetLang) return false;
67
+ return true;
68
+ }
69
+
70
+ /**
71
+ * Translate single text
72
+ */
73
+ async translateText(
74
+ text: string,
75
+ targetLanguage: LanguageCode,
76
+ options?: TranslateOptions
77
+ ): Promise<string> {
78
+ if (!text || !text.trim()) return text;
79
+
80
+ const sourceLang = options?.sourceLanguage ?? 'auto';
81
+ const actualSource = sourceLang === 'auto' ? this.detectLanguage(text) : sourceLang;
82
+
83
+ if (!this.needsTranslation(text, actualSource, targetLanguage)) {
84
+ return text;
85
+ }
86
+
87
+ // Check cache
88
+ const cached = this.cache.get(text, actualSource, targetLanguage);
89
+ if (cached) return cached;
90
+
91
+ // Call LLM
92
+ const response = await this.client.chat(
93
+ `Translate this text to ${targetLanguage}. Return ONLY the translation:\n\n${text}`,
94
+ {
95
+ ...options,
96
+ model: options?.model ?? this.defaultModel,
97
+ temperature: 0,
98
+ }
99
+ );
100
+
101
+ const translated = response.content.trim();
102
+ this.cache.set(text, actualSource, targetLanguage, translated);
103
+
104
+ return translated;
105
+ }
106
+
107
+ /**
108
+ * Translate JSON object with smart text-level caching
109
+ *
110
+ * @example
111
+ * ```ts
112
+ * const translator = new JsonTranslator(llm)
113
+ * const result = await translator.translate(
114
+ * { title: 'Hello', items: ['World', 'Earth'] },
115
+ * 'ru'
116
+ * )
117
+ * // { data: { title: 'Привет', items: ['Мир', 'Земля'] }, valid: true, ... }
118
+ * ```
119
+ */
120
+ async translate<T extends Record<string, unknown>>(
121
+ data: T,
122
+ targetLanguage: LanguageCode,
123
+ options?: TranslateOptions
124
+ ): Promise<TranslateResult<T>> {
125
+ const sourceLang = options?.sourceLanguage ?? 'auto';
126
+
127
+ // Extract all translatable texts
128
+ const translatableTexts = this.extractTranslatableTexts(data, sourceLang, targetLanguage);
129
+
130
+ if (translatableTexts.size === 0) {
131
+ return {
132
+ data,
133
+ valid: true,
134
+ errors: [],
135
+ retries: 0,
136
+ sourceLanguage: sourceLang,
137
+ targetLanguage,
138
+ };
139
+ }
140
+
141
+ // Detect source language from first text if auto
142
+ let actualSource = sourceLang;
143
+ if (sourceLang === 'auto') {
144
+ const firstText = [...translatableTexts][0];
145
+ actualSource = this.detectLanguage(firstText);
146
+ }
147
+
148
+ // Check cache for each text
149
+ const { cached, uncached } = this.cache.getMany(
150
+ [...translatableTexts],
151
+ actualSource,
152
+ targetLanguage
153
+ );
154
+
155
+ // If everything cached, apply and return
156
+ if (uncached.length === 0) {
157
+ return {
158
+ data: this.applyTranslations(data, cached),
159
+ valid: true,
160
+ errors: [],
161
+ retries: 0,
162
+ sourceLanguage: actualSource,
163
+ targetLanguage,
164
+ };
165
+ }
166
+
167
+ // Create partial JSON with only uncached texts
168
+ const partialJson = this.createPartialJson(data, new Set(uncached));
169
+ const jsonStr = JSON.stringify(partialJson, null, 2);
170
+
171
+ try {
172
+ // Build prompt and call LLM
173
+ const prompt = buildJsonTranslationPrompt(jsonStr, actualSource, targetLanguage);
174
+
175
+ const response = await this.client.chat(prompt, {
176
+ ...options,
177
+ model: options?.model ?? this.defaultModel,
178
+ temperature: 0,
179
+ });
180
+
181
+ // Parse response
182
+ const translatedPartial = extractJson<Record<string, unknown>>(response.content);
183
+
184
+ // Extract translations by comparison
185
+ const newTranslations = this.extractTranslationsByComparison(
186
+ partialJson,
187
+ translatedPartial,
188
+ uncached
189
+ );
190
+
191
+ // Cache new translations
192
+ this.cache.setMany(newTranslations, actualSource, targetLanguage);
193
+
194
+ // Combine all translations
195
+ const allTranslations = new Map([...cached, ...newTranslations]);
196
+
197
+ return {
198
+ data: this.applyTranslations(data, allTranslations),
199
+ valid: true,
200
+ errors: [],
201
+ retries: 0,
202
+ sourceLanguage: actualSource,
203
+ targetLanguage,
204
+ };
205
+ } catch (error) {
206
+ // On error, return what we have from cache
207
+ const errorMsg = error instanceof Error ? error.message : String(error);
208
+ console.error('Translation failed:', errorMsg);
209
+
210
+ return {
211
+ data: cached.size > 0 ? this.applyTranslations(data, cached) : data,
212
+ valid: false,
213
+ errors: [errorMsg],
214
+ retries: 0,
215
+ sourceLanguage: actualSource,
216
+ targetLanguage,
217
+ };
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Translate to multiple languages in parallel
223
+ */
224
+ async translateToMany<T extends Record<string, unknown>>(
225
+ data: T,
226
+ targetLanguages: LanguageCode[],
227
+ options?: TranslateOptions
228
+ ): Promise<Map<LanguageCode, TranslateResult<T>>> {
229
+ const results = new Map<LanguageCode, TranslateResult<T>>();
230
+
231
+ // Run translations in parallel
232
+ const promises = targetLanguages.map(async (lang) => {
233
+ const result = await this.translate(data, lang, options);
234
+ return { lang, result };
235
+ });
236
+
237
+ const settled = await Promise.all(promises);
238
+ for (const { lang, result } of settled) {
239
+ results.set(lang, result);
240
+ }
241
+
242
+ return results;
243
+ }
244
+
245
+ /**
246
+ * Extract all translatable texts from object
247
+ */
248
+ private extractTranslatableTexts(
249
+ obj: unknown,
250
+ sourceLang: string,
251
+ targetLang: string
252
+ ): Set<string> {
253
+ const texts = new Set<string>();
254
+
255
+ const extract = (item: unknown): void => {
256
+ if (typeof item === 'string') {
257
+ if (this.needsTranslation(item, sourceLang, targetLang)) {
258
+ texts.add(item);
259
+ }
260
+ } else if (Array.isArray(item)) {
261
+ for (const i of item) extract(i);
262
+ } else if (item !== null && typeof item === 'object') {
263
+ for (const v of Object.values(item)) extract(v);
264
+ }
265
+ };
266
+
267
+ extract(obj);
268
+ return texts;
269
+ }
270
+
271
+ /**
272
+ * Apply translations to object
273
+ */
274
+ private applyTranslations<T>(obj: T, translations: Map<string, string>): T {
275
+ const apply = (item: unknown): unknown => {
276
+ if (typeof item === 'string') {
277
+ return translations.get(item) ?? item;
278
+ } else if (Array.isArray(item)) {
279
+ return item.map(apply);
280
+ } else if (item !== null && typeof item === 'object') {
281
+ const result: Record<string, unknown> = {};
282
+ for (const [k, v] of Object.entries(item)) {
283
+ result[k] = apply(v);
284
+ }
285
+ return result;
286
+ }
287
+ return item;
288
+ };
289
+
290
+ return apply(obj) as T;
291
+ }
292
+
293
+ /**
294
+ * Create partial JSON with only texts that need translation
295
+ */
296
+ private createPartialJson(data: unknown, textsToInclude: Set<string>): unknown {
297
+ const filter = (obj: unknown): unknown => {
298
+ if (typeof obj === 'string') {
299
+ return textsToInclude.has(obj) ? obj : 'SKIP';
300
+ } else if (Array.isArray(obj)) {
301
+ return obj.map(filter).filter((i) => this.hasTranslatable(i, textsToInclude));
302
+ } else if (obj !== null && typeof obj === 'object') {
303
+ const result: Record<string, unknown> = {};
304
+ for (const [k, v] of Object.entries(obj)) {
305
+ const filtered = filter(v);
306
+ if (this.hasTranslatable(filtered, textsToInclude)) {
307
+ result[k] = filtered;
308
+ }
309
+ }
310
+ return result;
311
+ }
312
+ return obj;
313
+ };
314
+
315
+ return filter(data);
316
+ }
317
+
318
+ /**
319
+ * Check if object contains translatable text
320
+ */
321
+ private hasTranslatable(obj: unknown, textsSet: Set<string>): boolean {
322
+ if (typeof obj === 'string') return textsSet.has(obj);
323
+ if (Array.isArray(obj)) return obj.some((i) => this.hasTranslatable(i, textsSet));
324
+ if (obj !== null && typeof obj === 'object') {
325
+ return Object.values(obj).some((v) => this.hasTranslatable(v, textsSet));
326
+ }
327
+ return false;
328
+ }
329
+
330
+ /**
331
+ * Extract translations by comparing original and translated JSON
332
+ */
333
+ private extractTranslationsByComparison(
334
+ original: unknown,
335
+ translated: unknown,
336
+ uncachedTexts: string[]
337
+ ): Map<string, string> {
338
+ const translations = new Map<string, string>();
339
+ const uncachedSet = new Set(uncachedTexts);
340
+
341
+ const compare = (orig: unknown, trans: unknown): void => {
342
+ if (typeof orig === 'string' && typeof trans === 'string') {
343
+ // Save translation if original was in uncached list and translation is valid
344
+ if (uncachedSet.has(orig) && trans !== 'SKIP' && trans.trim()) {
345
+ translations.set(orig, trans);
346
+ }
347
+ } else if (Array.isArray(orig) && Array.isArray(trans)) {
348
+ for (let i = 0; i < Math.min(orig.length, trans.length); i++) {
349
+ compare(orig[i], trans[i]);
350
+ }
351
+ } else if (
352
+ orig !== null &&
353
+ typeof orig === 'object' &&
354
+ trans !== null &&
355
+ typeof trans === 'object'
356
+ ) {
357
+ for (const key of Object.keys(orig as Record<string, unknown>)) {
358
+ if (key in (trans as Record<string, unknown>)) {
359
+ compare(
360
+ (orig as Record<string, unknown>)[key],
361
+ (trans as Record<string, unknown>)[key]
362
+ );
363
+ }
364
+ }
365
+ }
366
+ };
367
+
368
+ compare(original, translated);
369
+ return translations;
370
+ }
371
+
372
+ /**
373
+ * Get translation statistics
374
+ */
375
+ getStats() {
376
+ return this.cache.getStats();
377
+ }
378
+
379
+ /**
380
+ * Clear translation cache
381
+ */
382
+ clearCache(sourceLang?: string, targetLang?: string) {
383
+ this.cache.clear(sourceLang, targetLang);
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Translator options
389
+ */
390
+ export interface TranslatorConfig {
391
+ cache?: TranslationCache;
392
+ /** Model for translation (default: openai/gpt-4o-mini) */
393
+ model?: string;
394
+ }
395
+
396
+ /**
397
+ * Create JSON translator
398
+ */
399
+ export function createTranslator(
400
+ client: LLMClient,
401
+ configOrCache?: TranslationCache | TranslatorConfig
402
+ ): JsonTranslator {
403
+ // Support both old and new API
404
+ if (configOrCache instanceof TranslationCache) {
405
+ return new JsonTranslator(client, configOrCache);
406
+ }
407
+ return new JsonTranslator(client, configOrCache?.cache, configOrCache?.model);
408
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Translation prompts
3
+ */
4
+
5
+ import { LANGUAGE_NAMES } from './types';
6
+
7
+ /**
8
+ * Get language name from code
9
+ */
10
+ export function getLanguageName(code: string): string {
11
+ return LANGUAGE_NAMES[code] ?? code;
12
+ }
13
+
14
+ /**
15
+ * Build JSON translation prompt
16
+ */
17
+ export function buildJsonTranslationPrompt(
18
+ json: string,
19
+ sourceLanguage: string,
20
+ targetLanguage: string
21
+ ): string {
22
+ const sourceName = getLanguageName(sourceLanguage);
23
+ const targetName = getLanguageName(targetLanguage);
24
+
25
+ return `Translate all string VALUES in this JSON from ${sourceName} to ${targetName}.
26
+
27
+ Rules:
28
+ - Translate ALL string values to ${targetName}
29
+ - Keep JSON keys unchanged (English)
30
+ - Skip: URLs, emails, numbers, "SKIP"
31
+ - Keep placeholders: {name}, {{var}}, %s
32
+
33
+ ${json}
34
+
35
+ Return ONLY the JSON with all values translated to ${targetName}:`;
36
+ }
37
+
38
+ /**
39
+ * Build text translation prompt
40
+ */
41
+ export function buildTextTranslationPrompt(
42
+ text: string,
43
+ sourceLanguage: string,
44
+ targetLanguage: string
45
+ ): string {
46
+ const sourceName = getLanguageName(sourceLanguage);
47
+ const targetName = getLanguageName(targetLanguage);
48
+
49
+ return `Translate from ${sourceName} to ${targetName}.
50
+
51
+ RULES:
52
+ 1. Translate ONLY the text provided
53
+ 2. Preserve formatting, numbers, URLs
54
+ 3. Keep placeholders like {name}, {{var}} unchanged
55
+ 4. Return ONLY the translation, no explanations
56
+
57
+ Text: ${text}
58
+
59
+ Translation:`;
60
+ }
61
+
62
+ /**
63
+ * Build retry prompt with errors
64
+ */
65
+ export function buildRetryPrompt(
66
+ originalJson: string,
67
+ wrongJson: string,
68
+ errors: string[],
69
+ targetLanguage: string
70
+ ): string {
71
+ const targetName = getLanguageName(targetLanguage);
72
+
73
+ return `Your previous translation had errors. Fix them.
74
+
75
+ ERRORS FOUND:
76
+ ${errors.map((e) => `- ${e}`).join('\n')}
77
+
78
+ ORIGINAL JSON:
79
+ ${originalJson}
80
+
81
+ YOUR WRONG TRANSLATION:
82
+ ${wrongJson}
83
+
84
+ FIX THE ERRORS. Remember:
85
+ - JSON keys must stay in English
86
+ - Only translate string values
87
+ - Preserve all placeholders
88
+
89
+ Return ONLY the corrected JSON translated to ${targetName}:`;
90
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Text utilities for translation
3
+ */
4
+
5
+ /**
6
+ * Check if text is technical content that shouldn't be translated
7
+ */
8
+ export function isTechnicalContent(text: string): boolean {
9
+ const trimmed = text.trim();
10
+
11
+ // Empty or whitespace only
12
+ if (!trimmed) return true;
13
+
14
+ // URLs
15
+ if (/^(https?:\/\/|\/\/|www\.)/i.test(trimmed)) return true;
16
+
17
+ // Email addresses
18
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return true;
19
+
20
+ // File paths (starts with / or contains common extensions)
21
+ if (/^\/[a-zA-Z]/.test(trimmed)) return true;
22
+ if (/\.(js|ts|tsx|jsx|json|css|scss|html|md|py|go|rs)$/i.test(trimmed)) return true;
23
+
24
+ // Numbers only (including decimals and percentages)
25
+ if (/^[\d.,]+%?$/.test(trimmed)) return true;
26
+
27
+ // Technical identifiers (SCREAMING_SNAKE_CASE)
28
+ if (/^[A-Z][A-Z0-9_]*$/.test(trimmed)) return true;
29
+
30
+ // Placeholders like {name}, {{var}}, %s, $1
31
+ if (/^(\{[^}]+\}|\{\{[^}]+\}\}|%[sd]|\$\d+)$/.test(trimmed)) return true;
32
+
33
+ // Single special characters
34
+ if (/^[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]+$/.test(trimmed)) return true;
35
+
36
+ return false;
37
+ }
38
+
39
+ /**
40
+ * Check if text contains CJK characters (Chinese, Japanese, Korean)
41
+ */
42
+ export function containsCJK(text: string): boolean {
43
+ // CJK Unified Ideographs, Hiragana, Katakana, Hangul
44
+ return /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(text);
45
+ }
46
+
47
+ /**
48
+ * Check if text contains Cyrillic characters
49
+ */
50
+ export function containsCyrillic(text: string): boolean {
51
+ return /[\u0400-\u04ff]/.test(text);
52
+ }
53
+
54
+ /**
55
+ * Check if text contains Arabic characters
56
+ */
57
+ export function containsArabic(text: string): boolean {
58
+ return /[\u0600-\u06ff]/.test(text);
59
+ }
60
+
61
+ /**
62
+ * Detect script/language from text
63
+ */
64
+ export function detectScript(
65
+ text: string
66
+ ): 'cjk' | 'cyrillic' | 'arabic' | 'latin' | 'unknown' {
67
+ if (containsCJK(text)) return 'cjk';
68
+ if (containsCyrillic(text)) return 'cyrillic';
69
+ if (containsArabic(text)) return 'arabic';
70
+ if (/[a-zA-Z]/.test(text)) return 'latin';
71
+ return 'unknown';
72
+ }
73
+
74
+ /**
75
+ * Extract placeholders from text
76
+ */
77
+ export function extractPlaceholders(text: string): string[] {
78
+ const patterns = [
79
+ /\{[^}]+\}/g, // {name}
80
+ /\{\{[^}]+\}\}/g, // {{name}}
81
+ /%[sd]/g, // %s, %d
82
+ /\$\d+/g, // $1, $2
83
+ ];
84
+
85
+ const placeholders: string[] = [];
86
+ for (const pattern of patterns) {
87
+ const matches = text.match(pattern);
88
+ if (matches) {
89
+ placeholders.push(...matches);
90
+ }
91
+ }
92
+
93
+ return [...new Set(placeholders)];
94
+ }
95
+
96
+ /**
97
+ * Check if all placeholders are preserved in translation
98
+ */
99
+ export function validatePlaceholders(
100
+ original: string,
101
+ translated: string
102
+ ): { valid: boolean; missing: string[]; extra: string[] } {
103
+ const originalPlaceholders = extractPlaceholders(original);
104
+ const translatedPlaceholders = extractPlaceholders(translated);
105
+
106
+ const missing = originalPlaceholders.filter(
107
+ (p) => !translatedPlaceholders.includes(p)
108
+ );
109
+ const extra = translatedPlaceholders.filter(
110
+ (p) => !originalPlaceholders.includes(p)
111
+ );
112
+
113
+ return {
114
+ valid: missing.length === 0 && extra.length === 0,
115
+ missing,
116
+ extra,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Get values that need translation from JSON
122
+ */
123
+ export function extractTranslatableValues(
124
+ obj: unknown,
125
+ path: string = ''
126
+ ): Array<{ path: string; value: string }> {
127
+ const values: Array<{ path: string; value: string }> = [];
128
+
129
+ if (typeof obj === 'string') {
130
+ if (!isTechnicalContent(obj)) {
131
+ values.push({ path, value: obj });
132
+ }
133
+ } else if (Array.isArray(obj)) {
134
+ obj.forEach((item, index) => {
135
+ values.push(
136
+ ...extractTranslatableValues(item, path ? `${path}[${index}]` : `[${index}]`)
137
+ );
138
+ });
139
+ } else if (obj !== null && typeof obj === 'object') {
140
+ for (const [key, value] of Object.entries(obj)) {
141
+ values.push(
142
+ ...extractTranslatableValues(value, path ? `${path}.${key}` : key)
143
+ );
144
+ }
145
+ }
146
+
147
+ return values;
148
+ }