transduck 0.5.2 → 0.6.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.
Files changed (42) hide show
  1. package/dist/backend.d.ts +1 -0
  2. package/dist/backend.js +6 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +64 -12
  5. package/dist/config.d.ts +1 -0
  6. package/dist/config.js +2 -0
  7. package/dist/handler.d.ts +2 -0
  8. package/dist/handler.js +79 -41
  9. package/dist/index.d.ts +17 -2
  10. package/dist/index.js +191 -92
  11. package/dist/providers/claude-api.d.ts +1 -0
  12. package/dist/providers/claude-api.js +11 -0
  13. package/dist/providers/claude-code.d.ts +1 -0
  14. package/dist/providers/claude-code.js +6 -0
  15. package/dist/providers/index.d.ts +1 -0
  16. package/dist/providers/openai-provider.d.ts +1 -0
  17. package/dist/providers/openai-provider.js +17 -0
  18. package/dist/result.d.ts +19 -0
  19. package/dist/result.js +26 -0
  20. package/dist/scanner.js +14 -0
  21. package/dist/shared-store.d.ts +18 -0
  22. package/dist/shared-store.js +126 -0
  23. package/package.json +5 -1
  24. package/src/backend.ts +10 -0
  25. package/src/cli.ts +64 -12
  26. package/src/config.ts +4 -0
  27. package/src/handler.ts +81 -54
  28. package/src/index.ts +277 -98
  29. package/src/providers/claude-api.ts +16 -0
  30. package/src/providers/claude-code.ts +10 -0
  31. package/src/providers/index.ts +6 -0
  32. package/src/providers/openai-provider.ts +24 -0
  33. package/src/result.ts +30 -0
  34. package/src/scanner.ts +18 -0
  35. package/src/shared-store.ts +157 -0
  36. package/tests/ait.test.ts +152 -14
  37. package/tests/backend.test.ts +34 -1
  38. package/tests/cli.test.ts +33 -0
  39. package/tests/config.test.ts +40 -0
  40. package/tests/result.test.ts +62 -0
  41. package/tests/scanner.test.ts +38 -0
  42. package/tests/shared-store.test.ts +210 -0
package/src/index.ts CHANGED
@@ -1,22 +1,28 @@
1
1
  import { createHash } from 'crypto';
2
2
  import { loadConfig, type TransduckConfig } from './config.js';
3
3
  import { TranslationStore } from './storage.js';
4
- import { translate as backendTranslate, translatePlural as backendTranslatePlural } from './backend.js';
4
+ import { SharedStore } from './shared-store.js';
5
+ import { TranslationResult } from './result.js';
6
+ import { translate as backendTranslate, translatePlural as backendTranslatePlural, detectLanguage as backendDetectLanguage } from './backend.js';
5
7
  import { validateTranslation, extractPlaceholders } from './validation.js';
6
8
  import { getPluralCategory, getPluralCategories, interpolateVars } from './plural.js';
7
9
 
8
10
  interface State {
9
11
  config: TransduckConfig | null;
10
12
  store: TranslationStore | null;
13
+ sharedStore: SharedStore | null;
11
14
  targetLang: string | null;
12
15
  pendingTranslations: Map<string, Promise<string>>;
16
+ backgroundKeys: Set<string>;
13
17
  }
14
18
 
15
19
  let state: State = {
16
20
  config: null,
17
21
  store: null,
22
+ sharedStore: null,
18
23
  targetLang: null,
19
24
  pendingTranslations: new Map(),
25
+ backgroundKeys: new Set(),
20
26
  };
21
27
 
22
28
  function hash(text: string): string {
@@ -29,6 +35,17 @@ export async function initialize(config?: TransduckConfig): Promise<void> {
29
35
  await store.initialize();
30
36
  state.config = cfg;
31
37
  state.store = store;
38
+
39
+ if (cfg.sharedUrl) {
40
+ try {
41
+ const shared = new SharedStore(cfg.sharedUrl);
42
+ await shared.initialize();
43
+ state.sharedStore = shared;
44
+ } catch (err) {
45
+ console.warn(`[transduck] Could not connect to shared store: ${(err as Error).message}`);
46
+ state.sharedStore = null;
47
+ }
48
+ }
32
49
  }
33
50
 
34
51
  export function setLanguage(lang: string): void {
@@ -36,18 +53,36 @@ export function setLanguage(lang: string): void {
36
53
  }
37
54
 
38
55
  export function _resetState(): void {
39
- state = { config: null, store: null, targetLang: null, pendingTranslations: new Map() };
56
+ state = {
57
+ config: null, store: null, sharedStore: null, targetLang: null,
58
+ pendingTranslations: new Map(), backgroundKeys: new Set(),
59
+ };
40
60
  }
41
61
 
42
62
  export function _getStore(): TranslationStore | null {
43
63
  return state.store;
44
64
  }
45
65
 
66
+ export function _getSharedStore(): SharedStore | null {
67
+ return state.sharedStore;
68
+ }
69
+
70
+ export function _setSharedStore(shared: SharedStore | null): void {
71
+ state.sharedStore = shared;
72
+ }
73
+
74
+ export interface AitOptions {
75
+ context?: string;
76
+ vars?: Record<string, string | number>;
77
+ sourceLang?: string;
78
+ background?: boolean;
79
+ }
80
+
46
81
  export async function ait(
47
82
  sourceText: string,
48
- context?: string,
83
+ contextOrOpts?: string | AitOptions,
49
84
  vars?: Record<string, string | number>,
50
- ): Promise<string> {
85
+ ): Promise<TranslationResult> {
51
86
  if (!state.config || !state.store) {
52
87
  throw new Error('transduck not initialized. Call initialize() first.');
53
88
  }
@@ -55,86 +90,170 @@ export async function ait(
55
90
  throw new Error('Target language not set. Call setLanguage() first.');
56
91
  }
57
92
 
93
+ // Parse overloaded args: ait(text, context?, vars?) OR ait(text, opts)
94
+ let context: string | undefined;
95
+ let resolvedVars: Record<string, string | number> | undefined;
96
+ let sourceLang: string | undefined;
97
+ let background = false;
98
+
99
+ if (typeof contextOrOpts === 'string') {
100
+ context = contextOrOpts;
101
+ resolvedVars = vars;
102
+ } else if (contextOrOpts != null) {
103
+ context = contextOrOpts.context;
104
+ resolvedVars = contextOrOpts.vars;
105
+ sourceLang = contextOrOpts.sourceLang;
106
+ background = contextOrOpts.background ?? false;
107
+ } else {
108
+ resolvedVars = vars;
109
+ }
110
+
58
111
  const cfg = state.config;
112
+ const effectiveSourceLang = sourceLang?.toUpperCase() ?? cfg.sourceLang;
113
+ const targetLang = state.targetLang;
59
114
 
60
- if (state.targetLang === cfg.sourceLang) {
61
- return interpolateVars(sourceText, vars);
115
+ if (targetLang === effectiveSourceLang) {
116
+ return new TranslationResult(
117
+ interpolateVars(sourceText, resolvedVars),
118
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
119
+ );
62
120
  }
63
121
 
64
122
  const projectContextHash = hash(cfg.projectContext);
65
123
  const stringContextHash = hash(context ?? '');
66
-
67
- // Cache lookup
68
- const cached = await state.store.lookup({
69
- sourceText, sourceLang: cfg.sourceLang, targetLang: state.targetLang,
124
+ const lookupParams = {
125
+ sourceText, sourceLang: effectiveSourceLang, targetLang,
70
126
  projectContextHash, stringContextHash,
71
- });
72
- if (cached !== null) return interpolateVars(cached, vars);
127
+ };
128
+
129
+ // Tier 1: local SQLite lookup
130
+ const cached = await state.store.lookup(lookupParams);
131
+ if (cached !== null) {
132
+ return new TranslationResult(
133
+ interpolateVars(cached, resolvedVars),
134
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
135
+ );
136
+ }
137
+
138
+ // Tier 2: shared Postgres lookup
139
+ if (state.sharedStore) {
140
+ const sharedCached = await state.sharedStore.lookup(lookupParams);
141
+ if (sharedCached !== null) {
142
+ // Propagate to local store
143
+ await state.store.insert({
144
+ ...lookupParams, stringContext: context ?? '',
145
+ translatedText: sharedCached, model: cfg.backendModel, status: 'translated',
146
+ });
147
+ return new TranslationResult(
148
+ interpolateVars(sharedCached, resolvedVars),
149
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
150
+ );
151
+ }
152
+ }
73
153
 
74
154
  // Read-only mode: skip backend, return source text
75
155
  if (cfg.readOnly) {
76
- return interpolateVars(sourceText, vars);
156
+ return new TranslationResult(
157
+ interpolateVars(sourceText, resolvedVars),
158
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
159
+ );
160
+ }
161
+
162
+ // Background mode: return immediately with pending=true
163
+ if (background) {
164
+ const bgKey = `${sourceText}|${effectiveSourceLang}|${targetLang}|${projectContextHash}|${stringContextHash}`;
165
+ if (!state.backgroundKeys.has(bgKey)) {
166
+ state.backgroundKeys.add(bgKey);
167
+ _doTranslate(sourceText, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg)
168
+ .finally(() => state.backgroundKeys.delete(bgKey));
169
+ }
170
+ return new TranslationResult(
171
+ interpolateVars(sourceText, resolvedVars),
172
+ { pending: true, sourceLang: effectiveSourceLang, lang: targetLang },
173
+ );
77
174
  }
78
175
 
79
176
  // In-process dedup
80
- const lockKey = `${sourceText}|${cfg.sourceLang}|${state.targetLang}|${projectContextHash}|${stringContextHash}`;
177
+ const lockKey = `${sourceText}|${effectiveSourceLang}|${targetLang}|${projectContextHash}|${stringContextHash}`;
81
178
 
82
179
  if (state.pendingTranslations.has(lockKey)) {
83
180
  const pending = await state.pendingTranslations.get(lockKey)!;
84
- return interpolateVars(pending, vars);
181
+ return new TranslationResult(
182
+ interpolateVars(pending, resolvedVars),
183
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
184
+ );
85
185
  }
86
186
 
87
- const translationPromise = (async () => {
88
- // Double-check after getting in
89
- const rechecked = await state.store!.lookup({
90
- sourceText, sourceLang: cfg.sourceLang, targetLang: state.targetLang!,
91
- projectContextHash, stringContextHash,
92
- });
93
- if (rechecked !== null) return rechecked;
187
+ const translationPromise = _doTranslate(sourceText, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg);
94
188
 
95
- try {
96
- const translated = await backendTranslate(
97
- sourceText, cfg.sourceLang, state.targetLang!,
98
- cfg.projectContext, context ?? null, cfg,
99
- );
189
+ state.pendingTranslations.set(lockKey, translationPromise);
190
+ const result = await translationPromise;
191
+ state.pendingTranslations.delete(lockKey);
192
+ return new TranslationResult(
193
+ interpolateVars(result, resolvedVars),
194
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
195
+ );
196
+ }
100
197
 
101
- if (!validateTranslation(sourceText, translated)) {
102
- console.warn(`[transduck] Validation failed for: ${sourceText} -> ${translated}`);
103
- await state.store!.insert({
104
- sourceText, sourceLang: cfg.sourceLang, targetLang: state.targetLang!,
105
- projectContextHash, stringContextHash, stringContext: context ?? '',
106
- translatedText: translated, model: cfg.backendModel, status: 'failed',
107
- });
108
- return sourceText;
109
- }
198
+ async function _doTranslate(
199
+ sourceText: string,
200
+ sourceLang: string,
201
+ targetLang: string,
202
+ projectContextHash: string,
203
+ stringContextHash: string,
204
+ stringContext: string,
205
+ cfg: TransduckConfig,
206
+ ): Promise<string> {
207
+ // Double-check local cache
208
+ const rechecked = await state.store!.lookup({
209
+ sourceText, sourceLang, targetLang, projectContextHash, stringContextHash,
210
+ });
211
+ if (rechecked !== null) return rechecked;
110
212
 
111
- await state.store!.insert({
112
- sourceText, sourceLang: cfg.sourceLang, targetLang: state.targetLang!,
113
- projectContextHash, stringContextHash, stringContext: context ?? '',
114
- translatedText: translated, model: cfg.backendModel, status: 'translated',
115
- });
116
- return translated;
117
- } catch (err) {
118
- console.warn(`[transduck] Backend failed for: ${sourceText}`, err);
213
+ try {
214
+ const translated = await backendTranslate(
215
+ sourceText, sourceLang, targetLang,
216
+ cfg.projectContext, stringContext || null, cfg,
217
+ );
218
+
219
+ const insertParams = {
220
+ sourceText, sourceLang, targetLang,
221
+ projectContextHash, stringContextHash, stringContext,
222
+ translatedText: translated, model: cfg.backendModel,
223
+ };
224
+
225
+ if (!validateTranslation(sourceText, translated)) {
226
+ console.warn(`[transduck] Validation failed for: ${sourceText} -> ${translated}`);
227
+ await state.store!.insert({ ...insertParams, status: 'failed' });
228
+ if (state.sharedStore) await state.sharedStore.insert({ ...insertParams, status: 'failed' });
119
229
  return sourceText;
120
- } finally {
121
- state.pendingTranslations.delete(lockKey);
122
230
  }
123
- })();
124
231
 
125
- state.pendingTranslations.set(lockKey, translationPromise);
126
- const result = await translationPromise;
127
- return interpolateVars(result, vars);
232
+ await state.store!.insert({ ...insertParams, status: 'translated' });
233
+ if (state.sharedStore) await state.sharedStore.insert({ ...insertParams, status: 'translated' });
234
+ return translated;
235
+ } catch (err) {
236
+ console.warn(`[transduck] Backend failed for: ${sourceText}`, err);
237
+ return sourceText;
238
+ }
128
239
  }
129
240
 
130
241
  export { createTransDuckHandler } from './handler.js';
242
+ export { TranslationResult } from './result.js';
243
+ export { SharedStore } from './shared-store.js';
244
+ export async function detectLanguage(text: string): Promise<string> {
245
+ if (!state.config) {
246
+ throw new Error('transduck not initialized. Call initialize() first.');
247
+ }
248
+ return backendDetectLanguage(text, state.config);
249
+ }
131
250
 
132
251
  export async function aitPlural(
133
252
  one: string,
134
253
  other: string,
135
254
  count: number,
136
- opts?: { context?: string; vars?: Record<string, string | number> },
137
- ): Promise<string> {
255
+ opts?: { context?: string; vars?: Record<string, string | number>; sourceLang?: string; background?: boolean },
256
+ ): Promise<TranslationResult> {
138
257
  if (!state.config || !state.store) {
139
258
  throw new Error('transduck not initialized. Call initialize() first.');
140
259
  }
@@ -144,6 +263,8 @@ export async function aitPlural(
144
263
 
145
264
  const cfg = state.config;
146
265
  const context = opts?.context;
266
+ const effectiveSourceLang = opts?.sourceLang?.toUpperCase() ?? cfg.sourceLang;
267
+ const background = opts?.background ?? false;
147
268
 
148
269
  // Build vars with count
149
270
  let vars: Record<string, string | number>;
@@ -155,13 +276,18 @@ export async function aitPlural(
155
276
  vars = { ...opts.vars };
156
277
  }
157
278
 
279
+ const targetLang = state.targetLang;
280
+
158
281
  // Same language, 2-form language: select directly from provided forms
159
- if (state.targetLang === cfg.sourceLang) {
160
- const categories = getPluralCategories(cfg.sourceLang);
282
+ if (targetLang === effectiveSourceLang) {
283
+ const categories = getPluralCategories(effectiveSourceLang);
161
284
  if (categories.size <= 2) {
162
- const category = getPluralCategory(cfg.sourceLang, count);
285
+ const category = getPluralCategory(effectiveSourceLang, count);
163
286
  const form = category === 'one' ? one : other;
164
- return interpolateVars(form, vars);
287
+ return new TranslationResult(
288
+ interpolateVars(form, vars),
289
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
290
+ );
165
291
  }
166
292
  }
167
293
 
@@ -169,31 +295,102 @@ export async function aitPlural(
169
295
  const sourceKey = one + '\x00' + other;
170
296
  const projectContextHash = hash(cfg.projectContext);
171
297
  const stringContextHash = hash(context ?? '');
172
-
173
- // Cache lookup
174
- const cached = await state.store.lookupPlural({
175
- sourceText: sourceKey, sourceLang: cfg.sourceLang, targetLang: state.targetLang,
298
+ const lookupParams = {
299
+ sourceText: sourceKey, sourceLang: effectiveSourceLang, targetLang,
176
300
  projectContextHash, stringContextHash,
177
- });
301
+ };
178
302
 
179
- const category = getPluralCategory(state.targetLang, count);
303
+ const category = getPluralCategory(targetLang, count);
304
+
305
+ // Tier 1: local cache lookup
306
+ const cached = await state.store.lookupPlural(lookupParams);
180
307
  if (category in cached) {
181
- return interpolateVars(cached[category], vars);
308
+ return new TranslationResult(
309
+ interpolateVars(cached[category], vars),
310
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
311
+ );
312
+ }
313
+
314
+ // Tier 2: shared store lookup
315
+ if (state.sharedStore) {
316
+ const sharedCached = await state.sharedStore.lookupPlural(lookupParams);
317
+ if (category in sharedCached) {
318
+ // Propagate all forms to local store
319
+ for (const [cat, text] of Object.entries(sharedCached)) {
320
+ await state.store.insertPlural({
321
+ ...lookupParams, stringContext: context ?? '',
322
+ pluralCategory: cat, translatedText: text,
323
+ model: cfg.backendModel, status: 'translated',
324
+ });
325
+ }
326
+ return new TranslationResult(
327
+ interpolateVars(sharedCached[category], vars),
328
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
329
+ );
330
+ }
182
331
  }
183
332
 
184
333
  // Read-only mode: skip backend, fall back to source forms
185
334
  if (cfg.readOnly) {
186
- const fallbackCategory = getPluralCategory(cfg.sourceLang, count);
335
+ const fallbackCategory = getPluralCategory(effectiveSourceLang, count);
336
+ const fallback = fallbackCategory === 'one' ? one : other;
337
+ return new TranslationResult(
338
+ interpolateVars(fallback, vars),
339
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
340
+ );
341
+ }
342
+
343
+ // Background mode
344
+ if (background) {
345
+ const bgKey = `plural|${sourceKey}|${effectiveSourceLang}|${targetLang}|${projectContextHash}|${stringContextHash}`;
346
+ if (!state.backgroundKeys.has(bgKey)) {
347
+ state.backgroundKeys.add(bgKey);
348
+ _doPluralTranslate(one, other, sourceKey, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg)
349
+ .finally(() => state.backgroundKeys.delete(bgKey));
350
+ }
351
+ const fallbackCategory = getPluralCategory(effectiveSourceLang, count);
187
352
  const fallback = fallbackCategory === 'one' ? one : other;
188
- return interpolateVars(fallback, vars);
353
+ return new TranslationResult(
354
+ interpolateVars(fallback, vars),
355
+ { pending: true, sourceLang: effectiveSourceLang, lang: targetLang },
356
+ );
189
357
  }
190
358
 
191
359
  // Cache miss — call backend
360
+ const translatedText = await _doPluralTranslate(one, other, sourceKey, effectiveSourceLang, targetLang, projectContextHash, stringContextHash, context ?? '', cfg);
361
+
362
+ if (translatedText !== null && category in translatedText) {
363
+ return new TranslationResult(
364
+ interpolateVars(translatedText[category], vars),
365
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
366
+ );
367
+ }
368
+
369
+ // Fallback
370
+ const fallbackCategory = getPluralCategory(effectiveSourceLang, count);
371
+ const fallback = fallbackCategory === 'one' ? one : other;
372
+ return new TranslationResult(
373
+ interpolateVars(fallback, vars),
374
+ { pending: false, sourceLang: effectiveSourceLang, lang: targetLang },
375
+ );
376
+ }
377
+
378
+ async function _doPluralTranslate(
379
+ one: string,
380
+ other: string,
381
+ sourceKey: string,
382
+ sourceLang: string,
383
+ targetLang: string,
384
+ projectContextHash: string,
385
+ stringContextHash: string,
386
+ stringContext: string,
387
+ cfg: TransduckConfig,
388
+ ): Promise<Record<string, string> | null> {
192
389
  try {
193
390
  const forms = await backendTranslatePlural(
194
391
  one, other,
195
- cfg.sourceLang, state.targetLang,
196
- cfg.projectContext, context ?? null, cfg,
392
+ sourceLang, targetLang,
393
+ cfg.projectContext, stringContext || null, cfg,
197
394
  );
198
395
 
199
396
  // Validate and store each form
@@ -205,14 +402,16 @@ export async function aitPlural(
205
402
 
206
403
  if (typeof forms !== 'object' || forms === null || !('other' in forms)) {
207
404
  console.warn(`[transduck] Invalid plural response for: ${one} / ${other}`);
208
- const fallbackCategory = getPluralCategory(cfg.sourceLang, count);
209
- const fallback = fallbackCategory === 'one' ? one : other;
210
- return interpolateVars(fallback, vars);
405
+ return null;
211
406
  }
212
407
 
408
+ const lookupParams = {
409
+ sourceText: sourceKey, sourceLang, targetLang,
410
+ projectContextHash, stringContextHash,
411
+ };
412
+
213
413
  for (const [cat, text] of Object.entries(forms)) {
214
414
  if (!validCategories.has(cat) || !text) continue;
215
- // Validate placeholders
216
415
  const translatedPlaceholders = extractPlaceholders(text);
217
416
  let allPresent = true;
218
417
  for (const p of sourcePlaceholders) {
@@ -222,38 +421,18 @@ export async function aitPlural(
222
421
  }
223
422
  }
224
423
  const status = allPresent ? 'translated' : 'failed';
225
- await state.store!.insertPlural({
226
- sourceText: sourceKey, sourceLang: cfg.sourceLang, targetLang: state.targetLang!,
227
- projectContextHash, stringContextHash, stringContext: context ?? '',
424
+ const insertParams = {
425
+ ...lookupParams, stringContext,
228
426
  pluralCategory: cat, translatedText: text,
229
427
  model: cfg.backendModel, status,
230
- });
231
- }
232
-
233
- // Select the right form
234
- if (category in forms) {
235
- const text = forms[category];
236
- const tp = extractPlaceholders(text);
237
- let allPresent = true;
238
- for (const p of sourcePlaceholders) {
239
- if (!tp.has(p)) {
240
- allPresent = false;
241
- break;
242
- }
243
- }
244
- if (allPresent) {
245
- return interpolateVars(text, vars);
246
- }
428
+ };
429
+ await state.store!.insertPlural(insertParams);
430
+ if (state.sharedStore) await state.sharedStore.insertPlural(insertParams);
247
431
  }
248
432
 
249
- // Fallback
250
- const fallbackCategory = getPluralCategory(cfg.sourceLang, count);
251
- const fallback = fallbackCategory === 'one' ? one : other;
252
- return interpolateVars(fallback, vars);
433
+ return forms;
253
434
  } catch (err) {
254
435
  console.warn(`[transduck] Backend failed for plural: ${one} / ${other}`, err);
255
- const fallbackCategory = getPluralCategory(cfg.sourceLang, count);
256
- const fallback = fallbackCategory === 'one' ? one : other;
257
- return interpolateVars(fallback, vars);
436
+ return null;
258
437
  }
259
438
  }
@@ -50,6 +50,22 @@ export async function translate(
50
50
  return response.content[0].text.trim();
51
51
  }
52
52
 
53
+ export async function detectLanguage(
54
+ text: string,
55
+ config: TransduckConfig,
56
+ ): Promise<string> {
57
+ const client = await getClient(config);
58
+ const response = await client.messages.create({
59
+ model: config.backendModel,
60
+ max_tokens: 16,
61
+ temperature: 0.0,
62
+ system: 'What language is this text written in? Return only the uppercase ISO 639-1 code.',
63
+ messages: [{ role: 'user', content: text }],
64
+ });
65
+
66
+ return response.content[0].text.trim().toUpperCase();
67
+ }
68
+
53
69
  export async function translatePlural(
54
70
  one: string,
55
71
  other: string,
@@ -54,6 +54,16 @@ export async function translate(
54
54
  return translateWithSdk(prompt);
55
55
  }
56
56
 
57
+ export async function detectLanguage(
58
+ text: string,
59
+ config: TransduckConfig,
60
+ ): Promise<string> {
61
+ ensureToken(config);
62
+ const prompt = 'What language is this text written in? Return only the uppercase ISO 639-1 code.\n\n' + text;
63
+ const raw = await translateWithSdk(prompt);
64
+ return raw.trim().toUpperCase();
65
+ }
66
+
57
67
  export async function translatePlural(
58
68
  one: string,
59
69
  other: string,
@@ -25,6 +25,12 @@ export interface TranslationProvider {
25
25
  config: TransduckConfig,
26
26
  _clientOverride?: any,
27
27
  ): Promise<Record<string, string>>;
28
+
29
+ detectLanguage(
30
+ text: string,
31
+ config: TransduckConfig,
32
+ _clientOverride?: any,
33
+ ): Promise<string>;
28
34
  }
29
35
 
30
36
  /**
@@ -35,6 +35,30 @@ export async function translate(
35
35
  return response.choices[0].message.content.trim();
36
36
  }
37
37
 
38
+ export async function detectLanguage(
39
+ text: string,
40
+ config: TransduckConfig,
41
+ _clientOverride?: any,
42
+ ): Promise<string> {
43
+ const apiKey = process.env[config.apiKeyEnv];
44
+ const client = _clientOverride ?? new OpenAI({
45
+ apiKey,
46
+ timeout: config.backendTimeout * 1000,
47
+ maxRetries: config.backendMaxRetries,
48
+ });
49
+
50
+ const response = await client.chat.completions.create({
51
+ model: config.backendModel,
52
+ messages: [
53
+ { role: 'system', content: 'What language is this text written in? Return only the uppercase ISO 639-1 code.' },
54
+ { role: 'user', content: text },
55
+ ],
56
+ temperature: 0.0,
57
+ });
58
+
59
+ return response.choices[0].message.content.trim().toUpperCase();
60
+ }
61
+
38
62
  export async function translatePlural(
39
63
  one: string,
40
64
  other: string,
package/src/result.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A string wrapper that carries translation metadata.
3
+ *
4
+ * Works in template literals, concatenation, and equality checks via
5
+ * toString() and valueOf() overrides, plus Symbol.toPrimitive.
6
+ */
7
+ export class TranslationResult extends String {
8
+ readonly pending: boolean;
9
+ readonly sourceLang: string;
10
+ readonly lang: string;
11
+
12
+ constructor(text: string, opts: { pending: boolean; sourceLang: string; lang: string }) {
13
+ super(text);
14
+ this.pending = opts.pending;
15
+ this.sourceLang = opts.sourceLang;
16
+ this.lang = opts.lang;
17
+ }
18
+
19
+ override toString(): string {
20
+ return super.toString();
21
+ }
22
+
23
+ override valueOf(): string {
24
+ return super.valueOf();
25
+ }
26
+
27
+ [Symbol.toPrimitive](_hint: string): string {
28
+ return super.valueOf();
29
+ }
30
+ }
package/src/scanner.ts CHANGED
@@ -57,10 +57,28 @@ function shouldSkipDir(dirname: string): boolean {
57
57
  return false;
58
58
  }
59
59
 
60
+ /**
61
+ * Join adjacent string literals so the regex can match multi-line strings.
62
+ *
63
+ * Handles:
64
+ * - Python implicit concatenation: "foo" \n "bar" -> "foobar"
65
+ * - JS + concatenation: "foo" + \n "bar" -> "foobar"
66
+ * - Both single and double quotes
67
+ */
68
+ function normalizeMultilineStrings(content: string): string {
69
+ // Join adjacent string literals: "..." \s+ "..." or '...' \s+ '...'
70
+ // Also handles "..." + \s+ "..." for JS
71
+ return content.replace(
72
+ /(['"])\s*\+?\s*\n\s*\1/g,
73
+ '',
74
+ );
75
+ }
76
+
60
77
  /**
61
78
  * Extract translatable strings from file content.
62
79
  */
63
80
  export function extractStrings(content: string, filename: string): ScanEntry[] {
81
+ content = normalizeMultilineStrings(content);
64
82
  const results: ScanEntry[] = [];
65
83
  const ext = extname(filename).toLowerCase();
66
84
  const isJs = JS_EXTENSIONS.has(ext);