transduck 0.0.5 → 0.1.2
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/README.md +88 -0
- package/dist/cli.js +17 -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.js +26 -0
- package/package.json +25 -1
- package/src/cli.ts +21 -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 +29 -0
- package/tests/handler.test.ts +136 -0
- package/tests/react-provider.test.tsx +162 -0
- package/tests/scanner.test.ts +24 -0
- package/tsconfig.json +2 -1
- package/vitest.config.ts +9 -0
|
@@ -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
|
+
});
|
package/tests/scanner.test.ts
CHANGED
|
@@ -100,6 +100,30 @@ ait_plural("{count} item", "{count} items", count=n)
|
|
|
100
100
|
const result = extractStrings('line1\nait("Hello")\nline3', 'test.py');
|
|
101
101
|
expect(result[0].line).toBe(2);
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
it('extracts t() from files with transduck/react import', () => {
|
|
105
|
+
const code = `import { t } from 'transduck/react';\nt("Hello")\nt("Book", "Hotel booking")`;
|
|
106
|
+
const result = extractStrings(code, 'test.tsx');
|
|
107
|
+
expect(result.length).toBe(2);
|
|
108
|
+
expect(result[0].text).toBe('Hello');
|
|
109
|
+
expect(result[1].text).toBe('Book');
|
|
110
|
+
expect(result[1].context).toBe('Hotel booking');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('does not extract t() from files without transduck/react import', () => {
|
|
114
|
+
const code = `import { t } from 'i18next';\nt("Hello")`;
|
|
115
|
+
const result = extractStrings(code, 'test.tsx');
|
|
116
|
+
expect(result.length).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('extracts tPlural() from files with transduck/react import', () => {
|
|
120
|
+
const code = `import { tPlural } from 'transduck/react';\ntPlural("{count} item", "{count} items", 5)`;
|
|
121
|
+
const result = extractStrings(code, 'test.tsx');
|
|
122
|
+
expect(result.length).toBe(1);
|
|
123
|
+
expect(result[0].plural).toBe(true);
|
|
124
|
+
expect(result[0].one).toBe('{count} item');
|
|
125
|
+
expect(result[0].other).toBe('{count} items');
|
|
126
|
+
});
|
|
103
127
|
});
|
|
104
128
|
|
|
105
129
|
describe('scanDirectory', () => {
|
package/tsconfig.json
CHANGED