transduck 0.2.5 → 0.4.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.
- package/README.md +3 -3
- package/dist/cli.js +3 -3
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +1 -1
- package/dist/react/provider.d.ts +27 -3
- package/dist/react/provider.js +104 -20
- package/dist/storage.d.ts +10 -5
- package/dist/storage.js +137 -153
- package/package.json +18 -10
- package/src/cli.ts +3 -3
- package/src/react/index.ts +2 -1
- package/src/react/provider.tsx +142 -18
- package/src/storage.ts +146 -167
- package/tests/ait.test.ts +1 -1
- package/tests/backend.test.ts +1 -1
- package/tests/cli.test.ts +6 -6
- package/tests/config.test.ts +2 -2
- package/tests/handler.test.ts +4 -4
- package/tests/providers.test.ts +5 -5
- package/tests/react-provider.test.tsx +363 -2
- package/tests/storage.test.ts +27 -59
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
AI-powered translation for apps and websites. Fast. Cheap. No keys to manage.
|
|
4
4
|
|
|
5
|
-
TransDuck translates your app's strings using AI, then caches them locally in
|
|
5
|
+
TransDuck translates your app's strings using AI, then caches them locally in LMDB so you never pay for the same translation twice. No translation files. No key naming. No sync headaches.
|
|
6
6
|
|
|
7
7
|
```typescript
|
|
8
8
|
import { initialize, setLanguage, ait, aitPlural } from 'transduck';
|
|
@@ -19,7 +19,7 @@ await aitPlural('{count} night', '{count} nights', 7); // → "7 Nächte"
|
|
|
19
19
|
|
|
20
20
|
- **No keys to manage** — your source text is the key
|
|
21
21
|
- **No translation files** — translations live in a single in-memory database file
|
|
22
|
-
- **Translate once, pay once** — cached lookups take ~
|
|
22
|
+
- **Translate once, pay once** — cached lookups take ~0.015ms, zero API costs after first call
|
|
23
23
|
- **AI that understands context** — project and per-string context for accurate translations
|
|
24
24
|
- **Pluralization that works everywhere** — handles Russian (4 forms), Arabic (6 forms) automatically
|
|
25
25
|
- **Built for vibe coding** — AI coding tools read the docs and wrap strings with `ait()` across your project
|
|
@@ -80,7 +80,7 @@ transduck stats # check your translation database
|
|
|
80
80
|
pip install transduck
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
Works with Django, Flask, FastAPI, and Jinja. Same
|
|
83
|
+
Works with Django, Flask, FastAPI, and Jinja. Same LMDB database format — share translations between Python and JS.
|
|
84
84
|
|
|
85
85
|
## Links
|
|
86
86
|
|
package/dist/cli.js
CHANGED
|
@@ -18,7 +18,7 @@ export async function runInit(opts) {
|
|
|
18
18
|
const config = {
|
|
19
19
|
project: { name: opts.name, context: opts.context },
|
|
20
20
|
languages: { source: opts.sourceLang.toUpperCase(), targets: opts.targetLangs.map(l => l.toUpperCase()) },
|
|
21
|
-
storage: { path: './translations.
|
|
21
|
+
storage: { path: './translations.lmdb' },
|
|
22
22
|
};
|
|
23
23
|
if (providerChoice === 2) {
|
|
24
24
|
config.backend = {
|
|
@@ -46,7 +46,7 @@ export async function runInit(opts) {
|
|
|
46
46
|
}
|
|
47
47
|
const configPath = join(opts.dir, 'transduck.yaml');
|
|
48
48
|
writeFileSync(configPath, yamlStringify(config));
|
|
49
|
-
const dbPath = join(opts.dir, 'translations.
|
|
49
|
+
const dbPath = join(opts.dir, 'translations.lmdb');
|
|
50
50
|
const store = new TranslationStore(dbPath);
|
|
51
51
|
await store.initialize();
|
|
52
52
|
store.close();
|
|
@@ -454,7 +454,7 @@ export async function runStats(opts) {
|
|
|
454
454
|
}
|
|
455
455
|
// CLI entry point
|
|
456
456
|
const program = new Command();
|
|
457
|
-
program.name('transduck').description('AI-native translation tool').version('0.
|
|
457
|
+
program.name('transduck').description('AI-native translation tool').version('0.4.0');
|
|
458
458
|
program.command('init')
|
|
459
459
|
.description('Initialize a new transduck project')
|
|
460
460
|
.action(async () => {
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState } from './provider.js';
|
|
1
|
+
export { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState, _getReactState } from './provider.js';
|
|
2
|
+
export type { UseTransDuckReturn } from './provider.js';
|
package/dist/react/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
export { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState } from './provider.js';
|
|
2
|
+
export { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState, _getReactState } from './provider.js';
|
package/dist/react/provider.d.ts
CHANGED
|
@@ -1,10 +1,34 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
+
interface ReactState {
|
|
3
|
+
language: string;
|
|
4
|
+
sourceLang: string;
|
|
5
|
+
endpoint: string;
|
|
6
|
+
projectName: string;
|
|
7
|
+
cache: Map<string, string>;
|
|
8
|
+
pluralCache: Map<string, Record<string, string>>;
|
|
9
|
+
pendingStrings: Set<string>;
|
|
10
|
+
pendingPlurals: Set<string>;
|
|
11
|
+
knownKeys: Set<string>;
|
|
12
|
+
knownPlurals: Set<string>;
|
|
13
|
+
isLanguageSwitch: boolean;
|
|
14
|
+
triggerFetch: (() => void) | null;
|
|
15
|
+
}
|
|
2
16
|
/**
|
|
3
|
-
* Hook that subscribes to translation updates
|
|
4
|
-
*
|
|
17
|
+
* Hook that subscribes to translation updates and returns translation
|
|
18
|
+
* functions along with language state.
|
|
5
19
|
*/
|
|
6
|
-
export
|
|
20
|
+
export interface UseTransDuckReturn {
|
|
21
|
+
t: typeof t;
|
|
22
|
+
tPlural: typeof tPlural;
|
|
23
|
+
ait: typeof t;
|
|
24
|
+
aitPlural: typeof tPlural;
|
|
25
|
+
language: string;
|
|
26
|
+
setLanguage: (lang: string) => void;
|
|
27
|
+
isLoading: boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare function useTransDuck(): UseTransDuckReturn;
|
|
7
30
|
export declare function _resetReactState(): void;
|
|
31
|
+
export declare function _getReactState(): ReactState;
|
|
8
32
|
export declare function t(sourceText: string, context?: string, vars?: Record<string, string | number>): string;
|
|
9
33
|
export declare function tPlural(one: string, other: string, count: number, opts?: {
|
|
10
34
|
context?: string;
|
package/dist/react/provider.js
CHANGED
|
@@ -1,39 +1,58 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
|
3
|
+
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
|
4
4
|
let _state = {
|
|
5
5
|
language: '',
|
|
6
6
|
sourceLang: 'EN',
|
|
7
7
|
endpoint: '/api/translations',
|
|
8
|
+
projectName: 'default',
|
|
8
9
|
cache: new Map(),
|
|
9
10
|
pluralCache: new Map(),
|
|
10
11
|
pendingStrings: new Set(),
|
|
11
12
|
pendingPlurals: new Set(),
|
|
13
|
+
knownKeys: new Set(),
|
|
14
|
+
knownPlurals: new Set(),
|
|
15
|
+
isLanguageSwitch: false,
|
|
12
16
|
triggerFetch: null,
|
|
13
17
|
};
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*/
|
|
18
|
+
const defaultContext = {
|
|
19
|
+
version: 0,
|
|
20
|
+
language: '',
|
|
21
|
+
setLanguage: () => { },
|
|
22
|
+
isLoading: false,
|
|
23
|
+
};
|
|
24
|
+
const TransDuckContext = createContext(defaultContext);
|
|
22
25
|
export function useTransDuck() {
|
|
23
|
-
useContext(TransDuckContext);
|
|
26
|
+
const ctx = useContext(TransDuckContext);
|
|
27
|
+
return {
|
|
28
|
+
t,
|
|
29
|
+
tPlural,
|
|
30
|
+
ait,
|
|
31
|
+
aitPlural,
|
|
32
|
+
language: ctx.language,
|
|
33
|
+
setLanguage: ctx.setLanguage,
|
|
34
|
+
isLoading: ctx.isLoading,
|
|
35
|
+
};
|
|
24
36
|
}
|
|
25
37
|
export function _resetReactState() {
|
|
26
38
|
_state = {
|
|
27
39
|
language: '',
|
|
28
40
|
sourceLang: 'EN',
|
|
29
41
|
endpoint: '/api/translations',
|
|
42
|
+
projectName: 'default',
|
|
30
43
|
cache: new Map(),
|
|
31
44
|
pluralCache: new Map(),
|
|
32
45
|
pendingStrings: new Set(),
|
|
33
46
|
pendingPlurals: new Set(),
|
|
47
|
+
knownKeys: new Set(),
|
|
48
|
+
knownPlurals: new Set(),
|
|
49
|
+
isLanguageSwitch: false,
|
|
34
50
|
triggerFetch: null,
|
|
35
51
|
};
|
|
36
52
|
}
|
|
53
|
+
export function _getReactState() {
|
|
54
|
+
return _state;
|
|
55
|
+
}
|
|
37
56
|
function interpolateVars(text, vars) {
|
|
38
57
|
if (!vars)
|
|
39
58
|
return text;
|
|
@@ -45,11 +64,12 @@ function interpolateVars(text, vars) {
|
|
|
45
64
|
}
|
|
46
65
|
// --- Stable exported functions ---
|
|
47
66
|
export function t(sourceText, context, vars) {
|
|
67
|
+
const key = `${sourceText}||${context ?? ''}`;
|
|
68
|
+
_state.knownKeys.add(key);
|
|
48
69
|
// Same language — return source
|
|
49
70
|
if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
|
|
50
71
|
return interpolateVars(sourceText, vars);
|
|
51
72
|
}
|
|
52
|
-
const key = `${sourceText}||${context ?? ''}`;
|
|
53
73
|
// Cache hit
|
|
54
74
|
const cached = _state.cache.get(key);
|
|
55
75
|
if (cached !== undefined) {
|
|
@@ -72,14 +92,15 @@ export function tPlural(one, other, count, opts) {
|
|
|
72
92
|
else {
|
|
73
93
|
vars = { ...opts.vars };
|
|
74
94
|
}
|
|
95
|
+
const sourceKey = `${one}\x00${other}`;
|
|
96
|
+
const cacheKey = `${sourceKey}||${context ?? ''}`;
|
|
97
|
+
_state.knownPlurals.add(cacheKey);
|
|
75
98
|
// Same language
|
|
76
99
|
if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
|
|
77
100
|
const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
|
|
78
101
|
const form = rules.select(count) === 'one' ? one : other;
|
|
79
102
|
return interpolateVars(form, vars);
|
|
80
103
|
}
|
|
81
|
-
const sourceKey = `${one}\x00${other}`;
|
|
82
|
-
const cacheKey = `${sourceKey}||${context ?? ''}`;
|
|
83
104
|
// Cache hit
|
|
84
105
|
const cachedForms = _state.pluralCache.get(cacheKey);
|
|
85
106
|
if (cachedForms) {
|
|
@@ -136,18 +157,52 @@ function saveToLocalStorage(projectName, language) {
|
|
|
136
157
|
}
|
|
137
158
|
export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/api/translations', projectName = 'default', children, }) {
|
|
138
159
|
const [version, setVersion] = useState(0);
|
|
160
|
+
const [languageState, setLanguageState] = useState(language.toUpperCase());
|
|
161
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
139
162
|
// Update module-level state synchronously so t() works during first render
|
|
140
163
|
_state.endpoint = endpoint;
|
|
141
164
|
_state.sourceLang = sourceLang;
|
|
165
|
+
_state.projectName = projectName;
|
|
166
|
+
// Synchronous first-render initialization only.
|
|
167
|
+
// Subsequent language changes are handled by switchLanguage / useEffect prop sync.
|
|
142
168
|
const upperLang = language.toUpperCase();
|
|
143
|
-
if (_state.language
|
|
169
|
+
if (!_state.language) {
|
|
144
170
|
_state.language = upperLang;
|
|
145
|
-
_state.cache.clear();
|
|
146
|
-
_state.pluralCache.clear();
|
|
147
171
|
loadFromLocalStorage(projectName, upperLang);
|
|
148
172
|
}
|
|
173
|
+
const switchLanguage = useCallback((lang) => {
|
|
174
|
+
const upper = lang.toUpperCase();
|
|
175
|
+
// Same-language no-op
|
|
176
|
+
if (upper === _state.language)
|
|
177
|
+
return;
|
|
178
|
+
// Update React state
|
|
179
|
+
setLanguageState(upper);
|
|
180
|
+
// Update module-level state synchronously
|
|
181
|
+
_state.language = upper;
|
|
182
|
+
_state.cache.clear();
|
|
183
|
+
_state.pluralCache.clear();
|
|
184
|
+
// Source-language guard: skip fetch, t() short-circuits
|
|
185
|
+
if (upper === _state.sourceLang.toUpperCase()) {
|
|
186
|
+
setVersion(v => v + 1);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// Load cached translations from localStorage
|
|
190
|
+
loadFromLocalStorage(_state.projectName, upper);
|
|
191
|
+
// Mark as language switch and re-queue known keys
|
|
192
|
+
_state.isLanguageSwitch = true;
|
|
193
|
+
setIsLoading(true);
|
|
194
|
+
for (const key of _state.knownKeys) {
|
|
195
|
+
_state.pendingStrings.add(key);
|
|
196
|
+
}
|
|
197
|
+
for (const key of _state.knownPlurals) {
|
|
198
|
+
_state.pendingPlurals.add(key);
|
|
199
|
+
}
|
|
200
|
+
// Bump version to trigger re-render and useEffect flush
|
|
201
|
+
setVersion(v => v + 1);
|
|
202
|
+
}, []);
|
|
149
203
|
// Set up trigger
|
|
150
204
|
const doFetch = useCallback(async () => {
|
|
205
|
+
const fetchLanguage = _state.language;
|
|
151
206
|
const stringsToFetch = new Set(_state.pendingStrings);
|
|
152
207
|
const pluralsToFetch = new Set(_state.pendingPlurals);
|
|
153
208
|
_state.pendingStrings.clear();
|
|
@@ -161,8 +216,13 @@ export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/ap
|
|
|
161
216
|
if (_state.pluralCache.has(key))
|
|
162
217
|
pluralsToFetch.delete(key);
|
|
163
218
|
}
|
|
164
|
-
if (stringsToFetch.size === 0 && pluralsToFetch.size === 0)
|
|
219
|
+
if (stringsToFetch.size === 0 && pluralsToFetch.size === 0) {
|
|
220
|
+
if (_state.isLanguageSwitch && _state.language === fetchLanguage) {
|
|
221
|
+
_state.isLanguageSwitch = false;
|
|
222
|
+
setIsLoading(false);
|
|
223
|
+
}
|
|
165
224
|
return;
|
|
225
|
+
}
|
|
166
226
|
// Build request body
|
|
167
227
|
const strings = Array.from(stringsToFetch).map(key => {
|
|
168
228
|
const [text, context] = key.split('||');
|
|
@@ -188,6 +248,9 @@ export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/ap
|
|
|
188
248
|
return;
|
|
189
249
|
}
|
|
190
250
|
const data = await response.json();
|
|
251
|
+
// Stale fetch protection: language changed while we were fetching
|
|
252
|
+
if (_state.language !== fetchLanguage)
|
|
253
|
+
return;
|
|
191
254
|
// Store translations
|
|
192
255
|
if (data.translations) {
|
|
193
256
|
for (const [key, value] of Object.entries(data.translations)) {
|
|
@@ -199,20 +262,41 @@ export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/ap
|
|
|
199
262
|
_state.pluralCache.set(key, value);
|
|
200
263
|
}
|
|
201
264
|
}
|
|
202
|
-
saveToLocalStorage(projectName, _state.language);
|
|
265
|
+
saveToLocalStorage(_state.projectName, _state.language);
|
|
203
266
|
setVersion(v => v + 1);
|
|
204
267
|
}
|
|
205
268
|
catch (err) {
|
|
206
269
|
console.warn('[transduck] Translation fetch error:', err);
|
|
207
270
|
}
|
|
208
|
-
|
|
271
|
+
finally {
|
|
272
|
+
if (_state.isLanguageSwitch && _state.language === fetchLanguage) {
|
|
273
|
+
_state.isLanguageSwitch = false;
|
|
274
|
+
setIsLoading(false);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}, [endpoint]);
|
|
209
278
|
// Register trigger
|
|
210
279
|
_state.triggerFetch = doFetch;
|
|
280
|
+
// Sync language prop changes — only when the prop itself changes
|
|
281
|
+
const prevPropRef = React.useRef(upperLang);
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
const upper = language.toUpperCase();
|
|
284
|
+
if (upper !== prevPropRef.current) {
|
|
285
|
+
prevPropRef.current = upper;
|
|
286
|
+
switchLanguage(upper);
|
|
287
|
+
}
|
|
288
|
+
}, [language, switchLanguage]);
|
|
211
289
|
// Flush pending after render
|
|
212
290
|
useEffect(() => {
|
|
213
291
|
if (_state.pendingStrings.size > 0 || _state.pendingPlurals.size > 0) {
|
|
214
292
|
doFetch();
|
|
215
293
|
}
|
|
216
294
|
});
|
|
217
|
-
|
|
295
|
+
const contextValue = {
|
|
296
|
+
version,
|
|
297
|
+
language: languageState,
|
|
298
|
+
setLanguage: switchLanguage,
|
|
299
|
+
isLoading,
|
|
300
|
+
};
|
|
301
|
+
return (_jsx(TransDuckContext.Provider, { value: contextValue, children: children }));
|
|
218
302
|
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -23,18 +23,23 @@ interface Stats {
|
|
|
23
23
|
}
|
|
24
24
|
export declare class TranslationStore {
|
|
25
25
|
private dbPath;
|
|
26
|
-
private
|
|
27
|
-
private conn;
|
|
26
|
+
private db;
|
|
28
27
|
constructor(dbPath: string);
|
|
29
|
-
private getConn;
|
|
30
|
-
private convertRow;
|
|
31
|
-
query(sql: string, params?: string[]): Promise<Record<string, unknown>[]>;
|
|
32
28
|
initialize(): Promise<void>;
|
|
29
|
+
private getDb;
|
|
33
30
|
lookup(params: LookupParams): Promise<string | null>;
|
|
34
31
|
insert(params: InsertParams): Promise<void>;
|
|
35
32
|
lookupPlural(params: LookupParams): Promise<Record<string, string>>;
|
|
36
33
|
insertPlural(params: InsertPluralParams): Promise<void>;
|
|
37
34
|
stats(): Promise<Stats>;
|
|
35
|
+
/**
|
|
36
|
+
* Count entries matching optional filters.
|
|
37
|
+
*/
|
|
38
|
+
count(targetLang?: string, failedOnly?: boolean): number;
|
|
39
|
+
/**
|
|
40
|
+
* Delete entries matching optional filters. Returns count deleted.
|
|
41
|
+
*/
|
|
42
|
+
clear(targetLang?: string, failedOnly?: boolean): Promise<number>;
|
|
38
43
|
close(): void;
|
|
39
44
|
}
|
|
40
45
|
export {};
|