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/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
+ });