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.
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +167 -2
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -0
- package/dist/handler.d.ts +23 -0
- package/dist/handler.js +153 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +11 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +1 -0
- package/dist/react/provider.d.ts +23 -0
- package/dist/react/provider.js +218 -0
- package/dist/scanner.d.ts +21 -0
- package/dist/scanner.js +180 -0
- package/package.json +25 -1
- package/src/cli.ts +195 -2
- package/src/config.ts +2 -0
- package/src/handler.ts +193 -0
- package/src/index.ts +14 -0
- package/src/react/index.ts +1 -0
- package/src/react/provider.tsx +287 -0
- package/src/scanner.ts +215 -0
- package/tests/cli.test.ts +63 -2
- package/tests/handler.test.ts +136 -0
- package/tests/react-provider.test.tsx +162 -0
- package/tests/scanner.test.ts +191 -0
- package/tsconfig.json +2 -1
- package/vitest.config.ts +9 -0
package/src/scanner.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source code scanner for TransDuck ait() and ait_plural()/aitPlural() calls.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readdirSync, readFileSync, statSync } from 'fs';
|
|
6
|
+
import { join, extname } from 'path';
|
|
7
|
+
|
|
8
|
+
// --- Types ---
|
|
9
|
+
|
|
10
|
+
export interface ScanEntry {
|
|
11
|
+
text?: string;
|
|
12
|
+
context?: string | null;
|
|
13
|
+
plural?: true;
|
|
14
|
+
one?: string;
|
|
15
|
+
other?: string;
|
|
16
|
+
line?: number;
|
|
17
|
+
files?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// --- Regex patterns ---
|
|
21
|
+
|
|
22
|
+
// ait("text") or ait("text", context="ctx") — Python/template keyword style
|
|
23
|
+
const AIT_KEYWORD_CTX = /ait\s*\(\s*(['"])(.*?)\1(?:\s*,\s*context\s*=\s*(['"])(.*?)\3)?/g;
|
|
24
|
+
|
|
25
|
+
// ait("text") or ait("text", "ctx") — JS positional style
|
|
26
|
+
const AIT_POSITIONAL_CTX = /ait\s*\(\s*(['"])(.*?)\1(?:\s*,\s*(['"])(.*?)\3)?/g;
|
|
27
|
+
|
|
28
|
+
// ait_plural("one", "other") or aitPlural("one", "other")
|
|
29
|
+
const AIT_PLURAL = /ait(?:_p|P)lural\s*\(\s*(['"])(.*?)\1\s*,\s*(['"])(.*?)\3/g;
|
|
30
|
+
|
|
31
|
+
// {% ait "text" %} or {% ait "text" context="ctx" %}
|
|
32
|
+
const DJANGO_TAG = /\{%\s*ait\s+(['"])(.*?)\1(?:\s+context=(['"])(.*?)\3)?\s*%\}/g;
|
|
33
|
+
|
|
34
|
+
// t("text") or t("text", "ctx") — only matched in files with transduck/react import
|
|
35
|
+
const T_POSITIONAL = /(?<![a-zA-Z_.$])t\s*\(\s*(['"])(.*?)\1(?:\s*,\s*(['"])(.*?)\3)?/g;
|
|
36
|
+
|
|
37
|
+
// tPlural("one", "other") — only matched in files with transduck/react import
|
|
38
|
+
const T_PLURAL = /(?<![a-zA-Z_.$])tPlural\s*\(\s*(['"])(.*?)\1\s*,\s*(['"])(.*?)\3/g;
|
|
39
|
+
|
|
40
|
+
const HAS_TRANSDUCK_REACT = /from\s+['"]transduck\/react['"]/;
|
|
41
|
+
|
|
42
|
+
// File extensions that use JS-style positional context
|
|
43
|
+
const JS_EXTENSIONS = new Set(['.js', '.ts', '.tsx', '.jsx']);
|
|
44
|
+
|
|
45
|
+
// File extensions that may contain Django template tags
|
|
46
|
+
const TEMPLATE_EXTENSIONS = new Set(['.html', '.jinja', '.jinja2']);
|
|
47
|
+
|
|
48
|
+
// Supported file extensions for scanning
|
|
49
|
+
const SCAN_EXTENSIONS = new Set(['.py', '.js', '.ts', '.tsx', '.jsx', '.html', '.jinja', '.jinja2']);
|
|
50
|
+
|
|
51
|
+
// Directories to skip
|
|
52
|
+
const SKIP_DIRS = new Set(['node_modules', '.venv', 'venv', '__pycache__', '.git', 'dist', 'build', '.next']);
|
|
53
|
+
|
|
54
|
+
function shouldSkipDir(dirname: string): boolean {
|
|
55
|
+
if (SKIP_DIRS.has(dirname)) return true;
|
|
56
|
+
if (dirname.includes('egg-info')) return true;
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Extract translatable strings from file content.
|
|
62
|
+
*/
|
|
63
|
+
export function extractStrings(content: string, filename: string): ScanEntry[] {
|
|
64
|
+
const results: ScanEntry[] = [];
|
|
65
|
+
const ext = extname(filename).toLowerCase();
|
|
66
|
+
const isJs = JS_EXTENSIONS.has(ext);
|
|
67
|
+
const isTemplate = TEMPLATE_EXTENSIONS.has(ext);
|
|
68
|
+
|
|
69
|
+
// Track positions of plural matches so we don't double-match them as ait()
|
|
70
|
+
const pluralSpans: Array<[number, number]> = [];
|
|
71
|
+
|
|
72
|
+
// 1. Find all plural calls
|
|
73
|
+
const pluralRegex = new RegExp(AIT_PLURAL.source, 'g');
|
|
74
|
+
let match: RegExpExecArray | null;
|
|
75
|
+
while ((match = pluralRegex.exec(content)) !== null) {
|
|
76
|
+
const one = match[2];
|
|
77
|
+
const other = match[4];
|
|
78
|
+
const lineNum = content.slice(0, match.index).split('\n').length;
|
|
79
|
+
results.push({
|
|
80
|
+
plural: true,
|
|
81
|
+
one,
|
|
82
|
+
other,
|
|
83
|
+
context: null,
|
|
84
|
+
line: lineNum,
|
|
85
|
+
});
|
|
86
|
+
pluralSpans.push([match.index, match.index + match[0].length]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 2. Find Django template tags (only in template files)
|
|
90
|
+
if (isTemplate) {
|
|
91
|
+
const djangoRegex = new RegExp(DJANGO_TAG.source, 'g');
|
|
92
|
+
while ((match = djangoRegex.exec(content)) !== null) {
|
|
93
|
+
const text = match[2];
|
|
94
|
+
const context = match[4] || null;
|
|
95
|
+
const lineNum = content.slice(0, match.index).split('\n').length;
|
|
96
|
+
results.push({ text, context, line: lineNum });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 3. Find ait() calls
|
|
101
|
+
const pattern = isJs ? AIT_POSITIONAL_CTX : AIT_KEYWORD_CTX;
|
|
102
|
+
const aitRegex = new RegExp(pattern.source, 'g');
|
|
103
|
+
while ((match = aitRegex.exec(content)) !== null) {
|
|
104
|
+
// Skip if this is part of a plural match
|
|
105
|
+
const pos = match.index;
|
|
106
|
+
if (pluralSpans.some(([start, end]) => pos >= start && pos < end)) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check that this is specifically "ait(" not "ait_plural(" or "aitPlural("
|
|
111
|
+
const prefixStart = Math.max(0, pos - 10);
|
|
112
|
+
const prefix = content.slice(prefixStart, pos + 4);
|
|
113
|
+
if (prefix.toLowerCase().includes('plural') || prefix.includes('Plural')) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const text = match[2];
|
|
118
|
+
const context = match[4] || null;
|
|
119
|
+
const lineNum = content.slice(0, pos).split('\n').length;
|
|
120
|
+
results.push({ text, context, line: lineNum });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 4. t() and tPlural() — only in files that import from transduck/react
|
|
124
|
+
if (HAS_TRANSDUCK_REACT.test(content)) {
|
|
125
|
+
// t() calls
|
|
126
|
+
for (const m of content.matchAll(new RegExp(T_POSITIONAL.source, 'g'))) {
|
|
127
|
+
const pos = m.index!;
|
|
128
|
+
// Skip if overlaps with plural spans
|
|
129
|
+
if (pluralSpans.some(([start, end]) => pos >= start && pos < end)) continue;
|
|
130
|
+
const text = m[2];
|
|
131
|
+
const context = m[4] || null;
|
|
132
|
+
const lineNum = content.slice(0, pos).split('\n').length;
|
|
133
|
+
results.push({ text, context, line: lineNum });
|
|
134
|
+
}
|
|
135
|
+
// tPlural() calls
|
|
136
|
+
for (const m of content.matchAll(new RegExp(T_PLURAL.source, 'g'))) {
|
|
137
|
+
const one = m[2];
|
|
138
|
+
const other = m[4];
|
|
139
|
+
const lineNum = content.slice(0, m.index!).split('\n').length;
|
|
140
|
+
results.push({ plural: true, one, other, context: null, line: lineNum });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return results;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Walk directories and extract all translatable strings.
|
|
149
|
+
* Returns deduplicated list of entries with 'files' field listing all locations.
|
|
150
|
+
*/
|
|
151
|
+
export function scanDirectory(dirs: string[]): ScanEntry[] {
|
|
152
|
+
const rawMatches = new Map<string, ScanEntry>();
|
|
153
|
+
|
|
154
|
+
for (const scanDir of dirs) {
|
|
155
|
+
walkDir(scanDir, rawMatches);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return Array.from(rawMatches.values());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function walkDir(dir: string, rawMatches: Map<string, ScanEntry>): void {
|
|
162
|
+
let entries: string[];
|
|
163
|
+
try {
|
|
164
|
+
entries = readdirSync(dir);
|
|
165
|
+
} catch {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const name of entries) {
|
|
170
|
+
const fullPath = join(dir, name);
|
|
171
|
+
let stat;
|
|
172
|
+
try {
|
|
173
|
+
stat = statSync(fullPath);
|
|
174
|
+
} catch {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (stat.isDirectory()) {
|
|
179
|
+
if (!shouldSkipDir(name)) {
|
|
180
|
+
walkDir(fullPath, rawMatches);
|
|
181
|
+
}
|
|
182
|
+
} else if (stat.isFile()) {
|
|
183
|
+
const ext = extname(name).toLowerCase();
|
|
184
|
+
if (!SCAN_EXTENSIONS.has(ext)) continue;
|
|
185
|
+
|
|
186
|
+
let content: string;
|
|
187
|
+
try {
|
|
188
|
+
content = readFileSync(fullPath, 'utf-8');
|
|
189
|
+
} catch {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const entries = extractStrings(content, name);
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
// Build dedup key
|
|
196
|
+
let key: string;
|
|
197
|
+
if (entry.plural) {
|
|
198
|
+
key = `plural:${entry.one}\x00${entry.other}\x00${entry.context ?? ''}`;
|
|
199
|
+
} else {
|
|
200
|
+
key = `text:${entry.text}\x00${entry.context ?? ''}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const fileLoc = `${fullPath}:${entry.line}`;
|
|
204
|
+
|
|
205
|
+
if (rawMatches.has(key)) {
|
|
206
|
+
rawMatches.get(key)!.files!.push(fileLoc);
|
|
207
|
+
} else {
|
|
208
|
+
const resultEntry: ScanEntry = { ...entry, files: [fileLoc] };
|
|
209
|
+
delete resultEntry.line;
|
|
210
|
+
rawMatches.set(key, resultEntry);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
package/tests/cli.test.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { writeFileSync } from 'fs';
|
|
2
|
+
import { writeFileSync, existsSync, readFileSync, mkdirSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { mkdtempSync } from 'fs';
|
|
5
5
|
import { tmpdir } from 'os';
|
|
6
6
|
import { createHash } from 'crypto';
|
|
7
7
|
|
|
8
|
-
import { runInit, runStats, runTranslate, runTranslatePlural, runWarm } from '../src/cli.js';
|
|
8
|
+
import { runInit, runStats, runTranslate, runTranslatePlural, runWarm, runScan } from '../src/cli.js';
|
|
9
9
|
import { TranslationStore } from '../src/storage.js';
|
|
10
10
|
|
|
11
11
|
function hash(text: string): string {
|
|
@@ -159,4 +159,65 @@ describe('CLI functions', () => {
|
|
|
159
159
|
// Since plural forms already exist, should be skipped
|
|
160
160
|
expect(output).toContain('Skipped: 1');
|
|
161
161
|
});
|
|
162
|
+
|
|
163
|
+
// --- scan ---
|
|
164
|
+
|
|
165
|
+
it('scan finds strings', async () => {
|
|
166
|
+
const configPath = writeConfig(tmpDir);
|
|
167
|
+
// Create source files to scan
|
|
168
|
+
const srcDir = join(tmpDir, 'src');
|
|
169
|
+
mkdirSync(srcDir);
|
|
170
|
+
writeFileSync(join(srcDir, 'app.py'), 'from transduck import ait\nait("Hello")\nait("World", context="greeting")\n');
|
|
171
|
+
const output = await runScan({
|
|
172
|
+
dirs: [srcDir],
|
|
173
|
+
configPath,
|
|
174
|
+
});
|
|
175
|
+
expect(output).toContain('Hello');
|
|
176
|
+
expect(output).toContain('World');
|
|
177
|
+
expect(output).toContain('2');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('scan output JSON', async () => {
|
|
181
|
+
const configPath = writeConfig(tmpDir);
|
|
182
|
+
const srcDir = join(tmpDir, 'src');
|
|
183
|
+
mkdirSync(srcDir);
|
|
184
|
+
writeFileSync(join(srcDir, 'app.py'), 'ait("Hello")\n');
|
|
185
|
+
const outputFile = join(tmpDir, 'strings.json');
|
|
186
|
+
const output = await runScan({
|
|
187
|
+
dirs: [srcDir],
|
|
188
|
+
outputPath: outputFile,
|
|
189
|
+
configPath,
|
|
190
|
+
});
|
|
191
|
+
expect(output).toContain('Wrote');
|
|
192
|
+
expect(existsSync(outputFile)).toBe(true);
|
|
193
|
+
const data = JSON.parse(readFileSync(outputFile, 'utf-8'));
|
|
194
|
+
expect(data).toHaveLength(1);
|
|
195
|
+
expect(data[0].text).toBe('Hello');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('scan with warm', async () => {
|
|
199
|
+
const configPath = writeConfig(tmpDir);
|
|
200
|
+
const srcDir = join(tmpDir, 'src');
|
|
201
|
+
mkdirSync(srcDir);
|
|
202
|
+
writeFileSync(join(srcDir, 'app.py'), 'ait("Hello")\n');
|
|
203
|
+
|
|
204
|
+
// Pre-populate the DB so warm skips
|
|
205
|
+
const dbPath = join(tmpDir, 'translations.duckdb');
|
|
206
|
+
const store = new TranslationStore(dbPath);
|
|
207
|
+
await store.initialize();
|
|
208
|
+
await store.insert({
|
|
209
|
+
sourceText: 'Hello', sourceLang: 'EN', targetLang: 'DE',
|
|
210
|
+
projectContextHash: hash('A test site'), stringContextHash: hash(''),
|
|
211
|
+
translatedText: 'Hallo', model: 'gpt-4.1-mini', status: 'translated',
|
|
212
|
+
});
|
|
213
|
+
store.close();
|
|
214
|
+
|
|
215
|
+
const output = await runScan({
|
|
216
|
+
dirs: [srcDir],
|
|
217
|
+
warm: true,
|
|
218
|
+
langs: ['DE'],
|
|
219
|
+
configPath,
|
|
220
|
+
});
|
|
221
|
+
expect(output).toContain('Skipped: 1');
|
|
222
|
+
});
|
|
162
223
|
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { mkdtempSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
|
|
6
|
+
// We test the handler logic directly, not via HTTP
|
|
7
|
+
import { handleTranslationRequest, _resetHandlerStore } from '../src/handler.js';
|
|
8
|
+
|
|
9
|
+
function makeTmpDir(): string {
|
|
10
|
+
return mkdtempSync(join(tmpdir(), 'transduck-handler-test-'));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function writeConfig(dir: string): string {
|
|
14
|
+
const configPath = join(dir, 'transduck.yaml');
|
|
15
|
+
writeFileSync(configPath, `
|
|
16
|
+
project:
|
|
17
|
+
name: test-project
|
|
18
|
+
context: "A test site"
|
|
19
|
+
languages:
|
|
20
|
+
source: EN
|
|
21
|
+
targets:
|
|
22
|
+
- DE
|
|
23
|
+
storage:
|
|
24
|
+
path: ./translations.duckdb
|
|
25
|
+
backend:
|
|
26
|
+
api_key_env: OPENAI_API_KEY
|
|
27
|
+
model: gpt-4.1-mini
|
|
28
|
+
timeout_seconds: 10
|
|
29
|
+
max_retries: 2
|
|
30
|
+
`);
|
|
31
|
+
return configPath;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('handleTranslationRequest', () => {
|
|
35
|
+
let tmpDir: string;
|
|
36
|
+
let configPath: string;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
_resetHandlerStore();
|
|
40
|
+
tmpDir = makeTmpDir();
|
|
41
|
+
configPath = writeConfig(tmpDir);
|
|
42
|
+
process.env.OPENAI_API_KEY = 'test-key';
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns translations for strings from cache', async () => {
|
|
46
|
+
// Pre-populate cache via storage directly
|
|
47
|
+
const { TranslationStore } = await import('../src/storage.js');
|
|
48
|
+
const { createHash } = await import('crypto');
|
|
49
|
+
const hash = (t: string) => createHash('sha256').update(t).digest('hex');
|
|
50
|
+
|
|
51
|
+
const store = new TranslationStore(join(tmpDir, 'translations.duckdb'));
|
|
52
|
+
await store.initialize();
|
|
53
|
+
await store.insert({
|
|
54
|
+
sourceText: 'Hello', sourceLang: 'EN', targetLang: 'DE',
|
|
55
|
+
projectContextHash: hash('A test site'), stringContextHash: hash(''),
|
|
56
|
+
translatedText: 'Hallo', model: 'gpt-4.1-mini', status: 'translated',
|
|
57
|
+
});
|
|
58
|
+
store.close();
|
|
59
|
+
|
|
60
|
+
const result = await handleTranslationRequest({
|
|
61
|
+
language: 'DE',
|
|
62
|
+
strings: [{ text: 'Hello' }],
|
|
63
|
+
plurals: [],
|
|
64
|
+
}, configPath);
|
|
65
|
+
|
|
66
|
+
expect(result.translations['Hello||']).toBe('Hallo');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns empty translations for uncached strings without API key', async () => {
|
|
70
|
+
delete process.env.OPENAI_API_KEY;
|
|
71
|
+
const result = await handleTranslationRequest({
|
|
72
|
+
language: 'DE',
|
|
73
|
+
strings: [{ text: 'Unknown' }],
|
|
74
|
+
plurals: [],
|
|
75
|
+
}, configPath);
|
|
76
|
+
|
|
77
|
+
// Without API key, backend will fail, should return source text
|
|
78
|
+
expect(result.translations['Unknown||']).toBe('Unknown');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('handles context in cache key', async () => {
|
|
82
|
+
const { TranslationStore } = await import('../src/storage.js');
|
|
83
|
+
const { createHash } = await import('crypto');
|
|
84
|
+
const hash = (t: string) => createHash('sha256').update(t).digest('hex');
|
|
85
|
+
|
|
86
|
+
const store = new TranslationStore(join(tmpDir, 'translations.duckdb'));
|
|
87
|
+
await store.initialize();
|
|
88
|
+
await store.insert({
|
|
89
|
+
sourceText: 'Book', sourceLang: 'EN', targetLang: 'DE',
|
|
90
|
+
projectContextHash: hash('A test site'), stringContextHash: hash('Hotel booking'),
|
|
91
|
+
translatedText: 'Buchen', model: 'gpt-4.1-mini', status: 'translated',
|
|
92
|
+
});
|
|
93
|
+
store.close();
|
|
94
|
+
|
|
95
|
+
const result = await handleTranslationRequest({
|
|
96
|
+
language: 'DE',
|
|
97
|
+
strings: [{ text: 'Book', context: 'Hotel booking' }],
|
|
98
|
+
plurals: [],
|
|
99
|
+
}, configPath);
|
|
100
|
+
|
|
101
|
+
expect(result.translations['Book||Hotel booking']).toBe('Buchen');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('returns plural forms from cache', async () => {
|
|
105
|
+
const { TranslationStore } = await import('../src/storage.js');
|
|
106
|
+
const { createHash } = await import('crypto');
|
|
107
|
+
const hash = (t: string) => createHash('sha256').update(t).digest('hex');
|
|
108
|
+
|
|
109
|
+
const store = new TranslationStore(join(tmpDir, 'translations.duckdb'));
|
|
110
|
+
await store.initialize();
|
|
111
|
+
const sourceKey = '{count} item\x00{count} items';
|
|
112
|
+
await store.insertPlural({
|
|
113
|
+
sourceText: sourceKey, sourceLang: 'EN', targetLang: 'DE',
|
|
114
|
+
projectContextHash: hash('A test site'), stringContextHash: hash(''),
|
|
115
|
+
pluralCategory: 'one', translatedText: '{count} Artikel',
|
|
116
|
+
model: 'gpt-4.1-mini', status: 'translated',
|
|
117
|
+
});
|
|
118
|
+
await store.insertPlural({
|
|
119
|
+
sourceText: sourceKey, sourceLang: 'EN', targetLang: 'DE',
|
|
120
|
+
projectContextHash: hash('A test site'), stringContextHash: hash(''),
|
|
121
|
+
pluralCategory: 'other', translatedText: '{count} Artikel',
|
|
122
|
+
model: 'gpt-4.1-mini', status: 'translated',
|
|
123
|
+
});
|
|
124
|
+
store.close();
|
|
125
|
+
|
|
126
|
+
const result = await handleTranslationRequest({
|
|
127
|
+
language: 'DE',
|
|
128
|
+
strings: [],
|
|
129
|
+
plurals: [{ one: '{count} item', other: '{count} items' }],
|
|
130
|
+
}, configPath);
|
|
131
|
+
|
|
132
|
+
const pluralKey = '{count} item\x00{count} items||';
|
|
133
|
+
expect(result.plurals[pluralKey]).toBeDefined();
|
|
134
|
+
expect(result.plurals[pluralKey].one).toBe('{count} Artikel');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { render, screen, cleanup, waitFor } from '@testing-library/react';
|
|
4
|
+
import { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState } from '../src/react/index.js';
|
|
5
|
+
|
|
6
|
+
// Mock fetch globally
|
|
7
|
+
const mockFetch = vi.fn();
|
|
8
|
+
global.fetch = mockFetch;
|
|
9
|
+
|
|
10
|
+
// Mock localStorage
|
|
11
|
+
const localStorageStore: Record<string, string> = {};
|
|
12
|
+
const mockLocalStorage = {
|
|
13
|
+
getItem: vi.fn((key: string) => localStorageStore[key] ?? null),
|
|
14
|
+
setItem: vi.fn((key: string, value: string) => { localStorageStore[key] = value; }),
|
|
15
|
+
removeItem: vi.fn((key: string) => { delete localStorageStore[key]; }),
|
|
16
|
+
clear: vi.fn(() => { for (const k in localStorageStore) delete localStorageStore[k]; }),
|
|
17
|
+
};
|
|
18
|
+
Object.defineProperty(globalThis, 'localStorage', { value: mockLocalStorage, writable: true });
|
|
19
|
+
|
|
20
|
+
describe('TransDuckProvider + t()', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
_resetReactState();
|
|
23
|
+
mockFetch.mockReset();
|
|
24
|
+
mockLocalStorage.clear();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
cleanup();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('t() returns source text before translation loads', () => {
|
|
32
|
+
mockFetch.mockResolvedValue({
|
|
33
|
+
ok: true,
|
|
34
|
+
json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function TestComp() {
|
|
38
|
+
useTransDuck();
|
|
39
|
+
return <span data-testid="text">{t('Hello')}</span>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
render(
|
|
43
|
+
<TransDuckProvider language="DE">
|
|
44
|
+
<TestComp />
|
|
45
|
+
</TransDuckProvider>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Before fetch resolves, shows source text
|
|
49
|
+
expect(screen.getByTestId('text').textContent).toBe('Hello');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('t() and ait() are the same function', () => {
|
|
53
|
+
expect(t).toBe(ait);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('tPlural() and aitPlural() are the same function', () => {
|
|
57
|
+
expect(tPlural).toBe(aitPlural);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('t() returns source text when language matches source', () => {
|
|
61
|
+
function TestComp() {
|
|
62
|
+
useTransDuck();
|
|
63
|
+
return <span data-testid="text">{t('Hello')}</span>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
render(
|
|
67
|
+
<TransDuckProvider language="EN" sourceLang="EN">
|
|
68
|
+
<TestComp />
|
|
69
|
+
</TransDuckProvider>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(screen.getByTestId('text').textContent).toBe('Hello');
|
|
73
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('t() interpolates vars', () => {
|
|
77
|
+
function TestComp() {
|
|
78
|
+
useTransDuck();
|
|
79
|
+
return <span data-testid="text">{t('Hello {name}', undefined, { name: 'Tim' })}</span>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
render(
|
|
83
|
+
<TransDuckProvider language="EN" sourceLang="EN">
|
|
84
|
+
<TestComp />
|
|
85
|
+
</TransDuckProvider>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(screen.getByTestId('text').textContent).toBe('Hello Tim');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('t() batches and fetches translations after render', async () => {
|
|
92
|
+
mockFetch.mockResolvedValue({
|
|
93
|
+
ok: true,
|
|
94
|
+
json: async () => ({
|
|
95
|
+
translations: { 'Hello||': 'Hallo', 'World||': 'Welt' },
|
|
96
|
+
plurals: {},
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
function TestComp() {
|
|
101
|
+
useTransDuck();
|
|
102
|
+
return (
|
|
103
|
+
<div>
|
|
104
|
+
<span data-testid="a">{t('Hello')}</span>
|
|
105
|
+
<span data-testid="b">{t('World')}</span>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
render(
|
|
111
|
+
<TransDuckProvider language="DE">
|
|
112
|
+
<TestComp />
|
|
113
|
+
</TransDuckProvider>
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// After useEffect fires and fetch resolves
|
|
117
|
+
await waitFor(() => {
|
|
118
|
+
expect(screen.getByTestId('a').textContent).toBe('Hallo');
|
|
119
|
+
expect(screen.getByTestId('b').textContent).toBe('Welt');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Should have made exactly one fetch call (batched)
|
|
123
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('t() does not refetch cached strings', async () => {
|
|
127
|
+
mockFetch.mockResolvedValue({
|
|
128
|
+
ok: true,
|
|
129
|
+
json: async () => ({
|
|
130
|
+
translations: { 'Hello||': 'Hallo' },
|
|
131
|
+
plurals: {},
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
function TestComp() {
|
|
136
|
+
useTransDuck();
|
|
137
|
+
return <span data-testid="text">{t('Hello')}</span>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const { rerender } = render(
|
|
141
|
+
<TransDuckProvider language="DE">
|
|
142
|
+
<TestComp />
|
|
143
|
+
</TransDuckProvider>
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await waitFor(() => {
|
|
147
|
+
expect(screen.getByTestId('text').textContent).toBe('Hallo');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
mockFetch.mockClear();
|
|
151
|
+
|
|
152
|
+
// Re-render — should not fetch again
|
|
153
|
+
rerender(
|
|
154
|
+
<TransDuckProvider language="DE">
|
|
155
|
+
<TestComp />
|
|
156
|
+
</TransDuckProvider>
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
160
|
+
expect(screen.getByTestId('text').textContent).toBe('Hallo');
|
|
161
|
+
});
|
|
162
|
+
});
|