transduck 0.0.4 → 0.1.1

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.
@@ -0,0 +1,218 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext, useEffect, useState, useCallback } from 'react';
4
+ let _state = {
5
+ language: '',
6
+ sourceLang: 'EN',
7
+ endpoint: '/api/translations',
8
+ cache: new Map(),
9
+ pluralCache: new Map(),
10
+ pendingStrings: new Set(),
11
+ pendingPlurals: new Set(),
12
+ triggerFetch: null,
13
+ };
14
+ // --- Context for triggering child re-renders ---
15
+ // The context value (a version counter) changes when translations are loaded.
16
+ // Components that call useTransDuck() subscribe to this and re-render automatically.
17
+ const TransDuckContext = createContext(0);
18
+ /**
19
+ * Hook that subscribes to translation updates. Call this in any component
20
+ * that uses t()/ait() to ensure it re-renders when translations are loaded.
21
+ */
22
+ export function useTransDuck() {
23
+ useContext(TransDuckContext);
24
+ }
25
+ export function _resetReactState() {
26
+ _state = {
27
+ language: '',
28
+ sourceLang: 'EN',
29
+ endpoint: '/api/translations',
30
+ cache: new Map(),
31
+ pluralCache: new Map(),
32
+ pendingStrings: new Set(),
33
+ pendingPlurals: new Set(),
34
+ triggerFetch: null,
35
+ };
36
+ }
37
+ function interpolateVars(text, vars) {
38
+ if (!vars)
39
+ return text;
40
+ let result = text;
41
+ for (const [key, value] of Object.entries(vars)) {
42
+ result = result.replaceAll(`{${key}}`, String(value));
43
+ }
44
+ return result;
45
+ }
46
+ // --- Stable exported functions ---
47
+ export function t(sourceText, context, vars) {
48
+ // Same language — return source
49
+ if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
50
+ return interpolateVars(sourceText, vars);
51
+ }
52
+ const key = `${sourceText}||${context ?? ''}`;
53
+ // Cache hit
54
+ const cached = _state.cache.get(key);
55
+ if (cached !== undefined) {
56
+ return interpolateVars(cached, vars);
57
+ }
58
+ // Queue for fetch (useEffect in provider will flush after render)
59
+ _state.pendingStrings.add(key);
60
+ // Return source text as fallback
61
+ return interpolateVars(sourceText, vars);
62
+ }
63
+ export function tPlural(one, other, count, opts) {
64
+ const context = opts?.context;
65
+ let vars;
66
+ if (!opts?.vars) {
67
+ vars = { count };
68
+ }
69
+ else if (!('count' in opts.vars)) {
70
+ vars = { ...opts.vars, count };
71
+ }
72
+ else {
73
+ vars = { ...opts.vars };
74
+ }
75
+ // Same language
76
+ if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
77
+ const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
78
+ const form = rules.select(count) === 'one' ? one : other;
79
+ return interpolateVars(form, vars);
80
+ }
81
+ const sourceKey = `${one}\x00${other}`;
82
+ const cacheKey = `${sourceKey}||${context ?? ''}`;
83
+ // Cache hit
84
+ const cachedForms = _state.pluralCache.get(cacheKey);
85
+ if (cachedForms) {
86
+ const rules = new Intl.PluralRules(_state.language.toLowerCase());
87
+ const category = rules.select(count);
88
+ const form = cachedForms[category] ?? cachedForms['other'] ?? other;
89
+ return interpolateVars(form, vars);
90
+ }
91
+ // Queue for fetch (useEffect in provider will flush after render)
92
+ _state.pendingPlurals.add(cacheKey);
93
+ // Fallback to source form
94
+ const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
95
+ const form = rules.select(count) === 'one' ? one : other;
96
+ return interpolateVars(form, vars);
97
+ }
98
+ // Aliases
99
+ export const ait = t;
100
+ export const aitPlural = tPlural;
101
+ // --- LocalStorage helpers ---
102
+ function loadFromLocalStorage(projectName, language) {
103
+ try {
104
+ const storageKey = `transduck:${projectName}:${language}`;
105
+ const stored = localStorage.getItem(storageKey);
106
+ if (stored) {
107
+ const data = JSON.parse(stored);
108
+ if (data.translations) {
109
+ for (const [k, v] of Object.entries(data.translations)) {
110
+ _state.cache.set(k, v);
111
+ }
112
+ }
113
+ if (data.plurals) {
114
+ for (const [k, v] of Object.entries(data.plurals)) {
115
+ _state.pluralCache.set(k, v);
116
+ }
117
+ }
118
+ }
119
+ }
120
+ catch {
121
+ // localStorage not available or corrupt — ignore
122
+ }
123
+ }
124
+ function saveToLocalStorage(projectName, language) {
125
+ try {
126
+ const storageKey = `transduck:${projectName}:${language}`;
127
+ const data = {
128
+ translations: Object.fromEntries(_state.cache),
129
+ plurals: Object.fromEntries(_state.pluralCache),
130
+ };
131
+ localStorage.setItem(storageKey, JSON.stringify(data));
132
+ }
133
+ catch {
134
+ // localStorage not available — ignore
135
+ }
136
+ }
137
+ export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/api/translations', projectName = 'default', children, }) {
138
+ const [version, setVersion] = useState(0);
139
+ // Update module-level state synchronously so t() works during first render
140
+ _state.endpoint = endpoint;
141
+ _state.sourceLang = sourceLang;
142
+ const upperLang = language.toUpperCase();
143
+ if (_state.language !== upperLang) {
144
+ _state.language = upperLang;
145
+ _state.cache.clear();
146
+ _state.pluralCache.clear();
147
+ loadFromLocalStorage(projectName, upperLang);
148
+ }
149
+ // Set up trigger
150
+ const doFetch = useCallback(async () => {
151
+ const stringsToFetch = new Set(_state.pendingStrings);
152
+ const pluralsToFetch = new Set(_state.pendingPlurals);
153
+ _state.pendingStrings.clear();
154
+ _state.pendingPlurals.clear();
155
+ // Filter out already-cached
156
+ for (const key of stringsToFetch) {
157
+ if (_state.cache.has(key))
158
+ stringsToFetch.delete(key);
159
+ }
160
+ for (const key of pluralsToFetch) {
161
+ if (_state.pluralCache.has(key))
162
+ pluralsToFetch.delete(key);
163
+ }
164
+ if (stringsToFetch.size === 0 && pluralsToFetch.size === 0)
165
+ return;
166
+ // Build request body
167
+ const strings = Array.from(stringsToFetch).map(key => {
168
+ const [text, context] = key.split('||');
169
+ return { text, context: context || undefined };
170
+ });
171
+ const plurals = Array.from(pluralsToFetch).map(key => {
172
+ const [sourceKey, context] = key.split('||');
173
+ const [one, other] = sourceKey.split('\x00');
174
+ return { one, other, context: context || undefined };
175
+ });
176
+ try {
177
+ const response = await fetch(endpoint, {
178
+ method: 'POST',
179
+ headers: { 'Content-Type': 'application/json' },
180
+ body: JSON.stringify({
181
+ language: _state.language,
182
+ strings,
183
+ plurals,
184
+ }),
185
+ });
186
+ if (!response.ok) {
187
+ console.warn(`[transduck] Translation fetch failed: ${response.status}`);
188
+ return;
189
+ }
190
+ const data = await response.json();
191
+ // Store translations
192
+ if (data.translations) {
193
+ for (const [key, value] of Object.entries(data.translations)) {
194
+ _state.cache.set(key, value);
195
+ }
196
+ }
197
+ if (data.plurals) {
198
+ for (const [key, value] of Object.entries(data.plurals)) {
199
+ _state.pluralCache.set(key, value);
200
+ }
201
+ }
202
+ saveToLocalStorage(projectName, _state.language);
203
+ setVersion(v => v + 1);
204
+ }
205
+ catch (err) {
206
+ console.warn('[transduck] Translation fetch error:', err);
207
+ }
208
+ }, [endpoint, projectName]);
209
+ // Register trigger
210
+ _state.triggerFetch = doFetch;
211
+ // Flush pending after render
212
+ useEffect(() => {
213
+ if (_state.pendingStrings.size > 0 || _state.pendingPlurals.size > 0) {
214
+ doFetch();
215
+ }
216
+ });
217
+ return (_jsx(TransDuckContext.Provider, { value: version, children: children }));
218
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Source code scanner for TransDuck ait() and ait_plural()/aitPlural() calls.
3
+ */
4
+ export interface ScanEntry {
5
+ text?: string;
6
+ context?: string | null;
7
+ plural?: true;
8
+ one?: string;
9
+ other?: string;
10
+ line?: number;
11
+ files?: string[];
12
+ }
13
+ /**
14
+ * Extract translatable strings from file content.
15
+ */
16
+ export declare function extractStrings(content: string, filename: string): ScanEntry[];
17
+ /**
18
+ * Walk directories and extract all translatable strings.
19
+ * Returns deduplicated list of entries with 'files' field listing all locations.
20
+ */
21
+ export declare function scanDirectory(dirs: string[]): ScanEntry[];
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Source code scanner for TransDuck ait() and ait_plural()/aitPlural() calls.
3
+ */
4
+ import { readdirSync, readFileSync, statSync } from 'fs';
5
+ import { join, extname } from 'path';
6
+ // --- Regex patterns ---
7
+ // ait("text") or ait("text", context="ctx") — Python/template keyword style
8
+ const AIT_KEYWORD_CTX = /ait\s*\(\s*(['"])(.*?)\1(?:\s*,\s*context\s*=\s*(['"])(.*?)\3)?/g;
9
+ // ait("text") or ait("text", "ctx") — JS positional style
10
+ const AIT_POSITIONAL_CTX = /ait\s*\(\s*(['"])(.*?)\1(?:\s*,\s*(['"])(.*?)\3)?/g;
11
+ // ait_plural("one", "other") or aitPlural("one", "other")
12
+ const AIT_PLURAL = /ait(?:_p|P)lural\s*\(\s*(['"])(.*?)\1\s*,\s*(['"])(.*?)\3/g;
13
+ // {% ait "text" %} or {% ait "text" context="ctx" %}
14
+ const DJANGO_TAG = /\{%\s*ait\s+(['"])(.*?)\1(?:\s+context=(['"])(.*?)\3)?\s*%\}/g;
15
+ // t("text") or t("text", "ctx") — only matched in files with transduck/react import
16
+ const T_POSITIONAL = /(?<![a-zA-Z_.$])t\s*\(\s*(['"])(.*?)\1(?:\s*,\s*(['"])(.*?)\3)?/g;
17
+ // tPlural("one", "other") — only matched in files with transduck/react import
18
+ const T_PLURAL = /(?<![a-zA-Z_.$])tPlural\s*\(\s*(['"])(.*?)\1\s*,\s*(['"])(.*?)\3/g;
19
+ const HAS_TRANSDUCK_REACT = /from\s+['"]transduck\/react['"]/;
20
+ // File extensions that use JS-style positional context
21
+ const JS_EXTENSIONS = new Set(['.js', '.ts', '.tsx', '.jsx']);
22
+ // File extensions that may contain Django template tags
23
+ const TEMPLATE_EXTENSIONS = new Set(['.html', '.jinja', '.jinja2']);
24
+ // Supported file extensions for scanning
25
+ const SCAN_EXTENSIONS = new Set(['.py', '.js', '.ts', '.tsx', '.jsx', '.html', '.jinja', '.jinja2']);
26
+ // Directories to skip
27
+ const SKIP_DIRS = new Set(['node_modules', '.venv', 'venv', '__pycache__', '.git', 'dist', 'build', '.next']);
28
+ function shouldSkipDir(dirname) {
29
+ if (SKIP_DIRS.has(dirname))
30
+ return true;
31
+ if (dirname.includes('egg-info'))
32
+ return true;
33
+ return false;
34
+ }
35
+ /**
36
+ * Extract translatable strings from file content.
37
+ */
38
+ export function extractStrings(content, filename) {
39
+ const results = [];
40
+ const ext = extname(filename).toLowerCase();
41
+ const isJs = JS_EXTENSIONS.has(ext);
42
+ const isTemplate = TEMPLATE_EXTENSIONS.has(ext);
43
+ // Track positions of plural matches so we don't double-match them as ait()
44
+ const pluralSpans = [];
45
+ // 1. Find all plural calls
46
+ const pluralRegex = new RegExp(AIT_PLURAL.source, 'g');
47
+ let match;
48
+ while ((match = pluralRegex.exec(content)) !== null) {
49
+ const one = match[2];
50
+ const other = match[4];
51
+ const lineNum = content.slice(0, match.index).split('\n').length;
52
+ results.push({
53
+ plural: true,
54
+ one,
55
+ other,
56
+ context: null,
57
+ line: lineNum,
58
+ });
59
+ pluralSpans.push([match.index, match.index + match[0].length]);
60
+ }
61
+ // 2. Find Django template tags (only in template files)
62
+ if (isTemplate) {
63
+ const djangoRegex = new RegExp(DJANGO_TAG.source, 'g');
64
+ while ((match = djangoRegex.exec(content)) !== null) {
65
+ const text = match[2];
66
+ const context = match[4] || null;
67
+ const lineNum = content.slice(0, match.index).split('\n').length;
68
+ results.push({ text, context, line: lineNum });
69
+ }
70
+ }
71
+ // 3. Find ait() calls
72
+ const pattern = isJs ? AIT_POSITIONAL_CTX : AIT_KEYWORD_CTX;
73
+ const aitRegex = new RegExp(pattern.source, 'g');
74
+ while ((match = aitRegex.exec(content)) !== null) {
75
+ // Skip if this is part of a plural match
76
+ const pos = match.index;
77
+ if (pluralSpans.some(([start, end]) => pos >= start && pos < end)) {
78
+ continue;
79
+ }
80
+ // Check that this is specifically "ait(" not "ait_plural(" or "aitPlural("
81
+ const prefixStart = Math.max(0, pos - 10);
82
+ const prefix = content.slice(prefixStart, pos + 4);
83
+ if (prefix.toLowerCase().includes('plural') || prefix.includes('Plural')) {
84
+ continue;
85
+ }
86
+ const text = match[2];
87
+ const context = match[4] || null;
88
+ const lineNum = content.slice(0, pos).split('\n').length;
89
+ results.push({ text, context, line: lineNum });
90
+ }
91
+ // 4. t() and tPlural() — only in files that import from transduck/react
92
+ if (HAS_TRANSDUCK_REACT.test(content)) {
93
+ // t() calls
94
+ for (const m of content.matchAll(new RegExp(T_POSITIONAL.source, 'g'))) {
95
+ const pos = m.index;
96
+ // Skip if overlaps with plural spans
97
+ if (pluralSpans.some(([start, end]) => pos >= start && pos < end))
98
+ continue;
99
+ const text = m[2];
100
+ const context = m[4] || null;
101
+ const lineNum = content.slice(0, pos).split('\n').length;
102
+ results.push({ text, context, line: lineNum });
103
+ }
104
+ // tPlural() calls
105
+ for (const m of content.matchAll(new RegExp(T_PLURAL.source, 'g'))) {
106
+ const one = m[2];
107
+ const other = m[4];
108
+ const lineNum = content.slice(0, m.index).split('\n').length;
109
+ results.push({ plural: true, one, other, context: null, line: lineNum });
110
+ }
111
+ }
112
+ return results;
113
+ }
114
+ /**
115
+ * Walk directories and extract all translatable strings.
116
+ * Returns deduplicated list of entries with 'files' field listing all locations.
117
+ */
118
+ export function scanDirectory(dirs) {
119
+ const rawMatches = new Map();
120
+ for (const scanDir of dirs) {
121
+ walkDir(scanDir, rawMatches);
122
+ }
123
+ return Array.from(rawMatches.values());
124
+ }
125
+ function walkDir(dir, rawMatches) {
126
+ let entries;
127
+ try {
128
+ entries = readdirSync(dir);
129
+ }
130
+ catch {
131
+ return;
132
+ }
133
+ for (const name of entries) {
134
+ const fullPath = join(dir, name);
135
+ let stat;
136
+ try {
137
+ stat = statSync(fullPath);
138
+ }
139
+ catch {
140
+ continue;
141
+ }
142
+ if (stat.isDirectory()) {
143
+ if (!shouldSkipDir(name)) {
144
+ walkDir(fullPath, rawMatches);
145
+ }
146
+ }
147
+ else if (stat.isFile()) {
148
+ const ext = extname(name).toLowerCase();
149
+ if (!SCAN_EXTENSIONS.has(ext))
150
+ continue;
151
+ let content;
152
+ try {
153
+ content = readFileSync(fullPath, 'utf-8');
154
+ }
155
+ catch {
156
+ continue;
157
+ }
158
+ const entries = extractStrings(content, name);
159
+ for (const entry of entries) {
160
+ // Build dedup key
161
+ let key;
162
+ if (entry.plural) {
163
+ key = `plural:${entry.one}\x00${entry.other}\x00${entry.context ?? ''}`;
164
+ }
165
+ else {
166
+ key = `text:${entry.text}\x00${entry.context ?? ''}`;
167
+ }
168
+ const fileLoc = `${fullPath}:${entry.line}`;
169
+ if (rawMatches.has(key)) {
170
+ rawMatches.get(key).files.push(fileLoc);
171
+ }
172
+ else {
173
+ const resultEntry = { ...entry, files: [fileLoc] };
174
+ delete resultEntry.line;
175
+ rawMatches.set(key, resultEntry);
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
package/package.json CHANGED
@@ -1,10 +1,20 @@
1
1
  {
2
2
  "name": "transduck",
3
- "version": "0.0.4",
3
+ "version": "0.1.1",
4
4
  "description": "AI-native translation tool using source text as keys",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./react": {
14
+ "types": "./dist/react/index.d.ts",
15
+ "default": "./dist/react/index.js"
16
+ }
17
+ },
8
18
  "bin": {
9
19
  "transduck": "./dist/cli.js"
10
20
  },
@@ -21,7 +31,21 @@
21
31
  },
22
32
  "devDependencies": {
23
33
  "@types/node": "^22.0.0",
34
+ "@types/react": "^19.0.0",
35
+ "@types/react-dom": "^19.0.0",
36
+ "@testing-library/react": "^16.0.0",
37
+ "jsdom": "^25.0.0",
38
+ "react": "^19.0.0",
39
+ "react-dom": "^19.0.0",
24
40
  "typescript": "^5.0.0",
25
41
  "vitest": "^2.0.0"
42
+ },
43
+ "peerDependencies": {
44
+ "react": ">=18.0.0",
45
+ "react-dom": ">=18.0.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "react": { "optional": true },
49
+ "react-dom": { "optional": true }
26
50
  }
27
51
  }
package/src/cli.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { createHash } from 'crypto';
4
- import { readFileSync, writeFileSync, existsSync } from 'fs';
5
- import { resolve, join } from 'path';
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
5
+ import { resolve, join, dirname } from 'path';
6
6
  import { Command } from 'commander';
7
7
  import { stringify as yamlStringify } from 'yaml';
8
8
  import { loadConfig } from './config.js';
@@ -10,6 +10,7 @@ import { TranslationStore } from './storage.js';
10
10
  import { translate as backendTranslate, translatePlural as backendTranslatePlural } from './backend.js';
11
11
  import { validateTranslation, extractPlaceholders } from './validation.js';
12
12
  import { getPluralCategory, interpolateVars } from './plural.js';
13
+ import { scanDirectory, type ScanEntry } from './scanner.js';
13
14
 
14
15
  function hash(text: string): string {
15
16
  return createHash('sha256').update(text).digest('hex');
@@ -336,6 +337,161 @@ export async function runWarm(opts: WarmOptions): Promise<string> {
336
337
  return `Translated: ${translated} | Skipped: ${skipped} | Failed: ${failed}`;
337
338
  }
338
339
 
340
+ export interface ScanOptions {
341
+ dirs: string[];
342
+ warm?: boolean;
343
+ langs?: string[];
344
+ outputPath?: string;
345
+ configPath?: string;
346
+ }
347
+
348
+ export async function runScan(opts: ScanOptions): Promise<string> {
349
+ const cfg = loadConfig(opts.configPath);
350
+ const scanDirs = opts.dirs.length > 0 ? opts.dirs : [process.cwd()];
351
+ const entries = scanDirectory(scanDirs);
352
+
353
+ const regular = entries.filter(e => !e.plural);
354
+ const plurals = entries.filter(e => e.plural);
355
+
356
+ // Count scanned files
357
+ const allFiles = new Set<string>();
358
+ for (const e of entries) {
359
+ for (const f of (e.files ?? [])) {
360
+ allFiles.add(f);
361
+ }
362
+ }
363
+
364
+ const lines: string[] = [];
365
+ lines.push(`Scanned files with matches: ${allFiles.size}`);
366
+ lines.push(`Found ${entries.length} strings (${regular.length} regular, ${plurals.length} plural)`);
367
+ lines.push('');
368
+
369
+ for (const e of entries) {
370
+ const locations = (e.files ?? []).join(', ');
371
+ if (e.plural) {
372
+ lines.push(` ait_plural("${e.one}", "${e.other}") ${locations}`);
373
+ } else {
374
+ const ctx = e.context ? `, context="${e.context}"` : '';
375
+ lines.push(` ait("${e.text}"${ctx}) ${locations}`);
376
+ }
377
+ }
378
+
379
+ // Output to JSON
380
+ if (opts.outputPath) {
381
+ const outputEntries = entries.map(e => {
382
+ const out: Record<string, unknown> = {};
383
+ for (const [k, v] of Object.entries(e)) {
384
+ if (k !== 'files') out[k] = v;
385
+ }
386
+ return out;
387
+ });
388
+ writeFileSync(opts.outputPath, JSON.stringify(outputEntries, null, 2));
389
+ lines.push(`\nWrote ${entries.length} entries to ${opts.outputPath}`);
390
+ }
391
+
392
+ // Warm
393
+ if (opts.warm) {
394
+ const targetLangs = opts.langs && opts.langs.length > 0
395
+ ? opts.langs.map(l => l.toUpperCase())
396
+ : cfg.targetLangs;
397
+
398
+ const store = new TranslationStore(cfg.storagePath);
399
+ await store.initialize();
400
+ const apiKey = process.env[cfg.apiKeyEnv];
401
+ const projectContextHash = hash(cfg.projectContext);
402
+
403
+ let translated = 0;
404
+ let skipped = 0;
405
+ let failed = 0;
406
+
407
+ for (const entry of entries) {
408
+ if (entry.plural) {
409
+ const sourceKey = entry.one + '\x00' + entry.other;
410
+ const stringContextHash = hash(entry.context ?? '');
411
+
412
+ for (const lang of targetLangs) {
413
+ const cachedForms = await store.lookupPlural({
414
+ sourceText: sourceKey, sourceLang: cfg.sourceLang, targetLang: lang,
415
+ projectContextHash, stringContextHash,
416
+ });
417
+
418
+ if (Object.keys(cachedForms).length > 0) {
419
+ skipped++;
420
+ continue;
421
+ }
422
+
423
+ try {
424
+ const forms = await backendTranslatePlural({
425
+ one: entry.one!, other: entry.other!,
426
+ sourceLang: cfg.sourceLang, targetLang: lang,
427
+ projectContext: cfg.projectContext, stringContext: entry.context ?? null,
428
+ apiKey: apiKey!, model: cfg.backendModel,
429
+ timeout: cfg.backendTimeout, maxRetries: cfg.backendMaxRetries,
430
+ });
431
+
432
+ for (const [cat, translatedText] of Object.entries(forms)) {
433
+ await store.insertPlural({
434
+ sourceText: sourceKey, sourceLang: cfg.sourceLang, targetLang: lang,
435
+ projectContextHash, stringContextHash,
436
+ pluralCategory: cat, translatedText: translatedText as string,
437
+ model: cfg.backendModel, status: 'translated',
438
+ });
439
+ }
440
+ translated++;
441
+ } catch {
442
+ failed++;
443
+ }
444
+ }
445
+ } else {
446
+ const stringContextHash = hash(entry.context ?? '');
447
+
448
+ for (const lang of targetLangs) {
449
+ const cached = await store.lookup({
450
+ sourceText: entry.text!, sourceLang: cfg.sourceLang, targetLang: lang,
451
+ projectContextHash, stringContextHash,
452
+ });
453
+
454
+ if (cached !== null) {
455
+ skipped++;
456
+ continue;
457
+ }
458
+
459
+ try {
460
+ const result = await backendTranslate({
461
+ sourceText: entry.text!, sourceLang: cfg.sourceLang, targetLang: lang,
462
+ projectContext: cfg.projectContext, stringContext: entry.context ?? null,
463
+ apiKey: apiKey!, model: cfg.backendModel,
464
+ timeout: cfg.backendTimeout, maxRetries: cfg.backendMaxRetries,
465
+ });
466
+
467
+ if (validateTranslation(entry.text!, result)) {
468
+ await store.insert({
469
+ sourceText: entry.text!, sourceLang: cfg.sourceLang, targetLang: lang,
470
+ projectContextHash, stringContextHash,
471
+ translatedText: result, model: cfg.backendModel, status: 'translated',
472
+ });
473
+ translated++;
474
+ } else {
475
+ failed++;
476
+ }
477
+ } catch {
478
+ failed++;
479
+ }
480
+ }
481
+ }
482
+ }
483
+
484
+ store.close();
485
+ lines.push(`\nTranslated: ${translated} | Skipped: ${skipped} | Failed: ${failed}`);
486
+ }
487
+
488
+ if (!opts.warm && !opts.outputPath) {
489
+ lines.push(`\nRun 'transduck scan --warm --langs DE,ES' to translate all strings.`);
490
+ }
491
+
492
+ return lines.join('\n');
493
+ }
494
+
339
495
  export interface StatsOptions {
340
496
  configPath?: string;
341
497
  }
@@ -384,6 +540,25 @@ program.command('init')
384
540
  targetLangs: targetsRaw.split(',').map(s => s.trim()),
385
541
  });
386
542
  console.log(output);
543
+
544
+ const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
545
+ const ask2 = (q: string): Promise<string> => new Promise(r => rl2.question(q, r));
546
+
547
+ const useReact = await ask2('Are you using React/Next.js? (y/n): ');
548
+ if (useReact.toLowerCase() === 'y') {
549
+ const routePath = await ask2('API route path (default: app/api/translations/route.ts): ');
550
+ const finalPath = join(dir, routePath.trim() || 'app/api/translations/route.ts');
551
+
552
+ // Create directory
553
+ const routeDir = dirname(finalPath);
554
+ mkdirSync(routeDir, { recursive: true });
555
+
556
+ // Write route file
557
+ writeFileSync(finalPath, `import { createTransDuckHandler } from 'transduck';\n\nexport const POST = createTransDuckHandler();\n`);
558
+ console.log(`Created ${finalPath}`);
559
+ console.log('Add TransDuckProvider to your layout — see docs: transduck/react');
560
+ }
561
+ rl2.close();
387
562
  });
388
563
 
389
564
  program.command('translate')
@@ -441,6 +616,24 @@ program.command('stats')
441
616
  console.log(output);
442
617
  });
443
618
 
619
+ program.command('scan')
620
+ .description('Scan source code for translatable strings')
621
+ .option('--dir <path...>', 'Directories to scan (repeatable)')
622
+ .option('--warm', 'Translate all found strings')
623
+ .option('--langs <langs>', 'Comma-separated target languages (for --warm)')
624
+ .option('--output <path>', 'Write found strings to JSON')
625
+ .option('--config <path>', 'Path to transduck.yaml')
626
+ .action(async (opts: { dir?: string[]; warm?: boolean; langs?: string; output?: string; config?: string }) => {
627
+ const output = await runScan({
628
+ dirs: opts.dir ?? [],
629
+ warm: opts.warm ?? false,
630
+ langs: opts.langs ? opts.langs.split(',').map(s => s.trim()) : undefined,
631
+ outputPath: opts.output,
632
+ configPath: opts.config,
633
+ });
634
+ console.log(output);
635
+ });
636
+
444
637
  export { program };
445
638
 
446
639
  // Run CLI when executed directly (not when imported by tests or other modules)