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 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 DuckDB so you never pay for the same translation twice. No translation files. No key naming. No sync headaches.
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 ~1.5ms, zero API costs after first call
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 DuckDB database format — share translations between Python and JS.
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.duckdb' },
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.duckdb');
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.2.5');
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 () => {
@@ -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';
@@ -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';
@@ -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. Call this in any component
4
- * that uses t()/ait() to ensure it re-renders when translations are loaded.
17
+ * Hook that subscribes to translation updates and returns translation
18
+ * functions along with language state.
5
19
  */
6
- export declare function useTransDuck(): void;
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;
@@ -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
- // --- 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
- */
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 !== upperLang) {
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
- }, [endpoint, projectName]);
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
- return (_jsx(TransDuckContext.Provider, { value: version, children: children }));
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 instance;
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 {};