transduck 0.6.8 → 0.6.10

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.js CHANGED
@@ -637,7 +637,7 @@ export async function runStats(opts) {
637
637
  }
638
638
  // CLI entry point
639
639
  const program = new Command();
640
- program.name('transduck').description('AI-native translation tool').version('0.6.8');
640
+ program.name('transduck').description('AI-native translation tool').version('0.6.10');
641
641
  program.command('init')
642
642
  .description('Initialize a new transduck project')
643
643
  .action(async () => {
package/dist/handler.d.ts CHANGED
@@ -12,6 +12,7 @@ interface TranslationRequestPlural {
12
12
  }
13
13
  export interface TranslationRequest {
14
14
  language: string;
15
+ sourceLang?: string;
15
16
  strings: TranslationRequestString[];
16
17
  plurals: TranslationRequestPlural[];
17
18
  }
package/dist/handler.js CHANGED
@@ -46,12 +46,13 @@ export async function handleTranslationRequest(body, configPath, opts) {
46
46
  const store = await getStore(configPath);
47
47
  const shared = await getSharedStore(configPath);
48
48
  const targetLang = body.language.toUpperCase();
49
+ const bodySourceLang = body.sourceLang?.toUpperCase();
49
50
  const projectContextHash = hash(cfg.projectContext);
50
51
  const translations = {};
51
52
  const plurals = {};
52
53
  // Translate regular strings
53
54
  for (const item of body.strings ?? []) {
54
- const sourceLang = item.sourceLang?.toUpperCase() ?? cfg.sourceLang;
55
+ const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
55
56
  const stringContextHash = hash(item.context ?? '');
56
57
  const key = `${item.text}||${item.context ?? ''}`;
57
58
  const lookupParams = {
@@ -109,7 +110,7 @@ export async function handleTranslationRequest(body, configPath, opts) {
109
110
  }
110
111
  // Translate plurals
111
112
  for (const item of body.plurals ?? []) {
112
- const sourceLang = item.sourceLang?.toUpperCase() ?? cfg.sourceLang;
113
+ const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
113
114
  const stringContextHash = hash(item.context ?? '');
114
115
  const sourceKey = item.one + '\x00' + item.other;
115
116
  const responseKey = `${sourceKey}||${item.context ?? ''}`;
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode } from 'react';
2
+ import { TranslationResult } from '../result.js';
2
3
  interface ReactState {
3
4
  language: string;
4
5
  sourceLang: string;
@@ -29,11 +30,11 @@ export interface UseTransDuckReturn {
29
30
  export declare function useTransDuck(): UseTransDuckReturn;
30
31
  export declare function _resetReactState(): void;
31
32
  export declare function _getReactState(): ReactState;
32
- export declare function t(sourceText: string, context?: string, vars?: Record<string, string | number>): string;
33
+ export declare function t(sourceText: string, context?: string, vars?: Record<string, string | number>): TranslationResult;
33
34
  export declare function tPlural(one: string, other: string, count: number, opts?: {
34
35
  context?: string;
35
36
  vars?: Record<string, string | number>;
36
- }): string;
37
+ }): TranslationResult;
37
38
  export declare const ait: typeof t;
38
39
  export declare const aitPlural: typeof tPlural;
39
40
  interface TransDuckProviderProps {
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
4
+ import { TranslationResult } from '../result.js';
4
5
  let _state = {
5
6
  language: '',
6
7
  sourceLang: 'EN',
@@ -82,18 +83,31 @@ export function t(sourceText, context, vars) {
82
83
  _state.knownKeys.add(key);
83
84
  // Same language — return source
84
85
  if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
85
- return interpolateVars(sourceText, vars);
86
+ return new TranslationResult(interpolateVars(sourceText, vars), {
87
+ pending: false,
88
+ sourceLang: _state.sourceLang,
89
+ lang: _state.language,
90
+ });
86
91
  }
87
92
  // Cache hit
88
93
  const cached = _state.cache.get(key);
89
94
  if (cached !== undefined) {
90
- return interpolateVars(cached, vars);
95
+ return new TranslationResult(interpolateVars(cached, vars), {
96
+ pending: false,
97
+ sourceLang: _state.sourceLang,
98
+ lang: _state.language,
99
+ source: 'cache',
100
+ });
91
101
  }
92
102
  // Queue for fetch
93
103
  _state.pendingStrings.add(key);
94
104
  schedulePendingFlush();
95
- // Return source text as fallback
96
- return interpolateVars(sourceText, vars);
105
+ // Return source text as fallback, marked pending
106
+ return new TranslationResult(interpolateVars(sourceText, vars), {
107
+ pending: true,
108
+ sourceLang: _state.sourceLang,
109
+ lang: _state.language,
110
+ });
97
111
  }
98
112
  export function tPlural(one, other, count, opts) {
99
113
  const context = opts?.context;
@@ -114,7 +128,11 @@ export function tPlural(one, other, count, opts) {
114
128
  if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
115
129
  const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
116
130
  const form = rules.select(count) === 'one' ? one : other;
117
- return interpolateVars(form, vars);
131
+ return new TranslationResult(interpolateVars(form, vars), {
132
+ pending: false,
133
+ sourceLang: _state.sourceLang,
134
+ lang: _state.language,
135
+ });
118
136
  }
119
137
  // Cache hit
120
138
  const cachedForms = _state.pluralCache.get(cacheKey);
@@ -122,15 +140,24 @@ export function tPlural(one, other, count, opts) {
122
140
  const rules = new Intl.PluralRules(_state.language.toLowerCase());
123
141
  const category = rules.select(count);
124
142
  const form = cachedForms[category] ?? cachedForms['other'] ?? other;
125
- return interpolateVars(form, vars);
143
+ return new TranslationResult(interpolateVars(form, vars), {
144
+ pending: false,
145
+ sourceLang: _state.sourceLang,
146
+ lang: _state.language,
147
+ source: 'cache',
148
+ });
126
149
  }
127
150
  // Queue for fetch
128
151
  _state.pendingPlurals.add(cacheKey);
129
152
  schedulePendingFlush();
130
- // Fallback to source form
153
+ // Fallback to source form, marked pending
131
154
  const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
132
155
  const form = rules.select(count) === 'one' ? one : other;
133
- return interpolateVars(form, vars);
156
+ return new TranslationResult(interpolateVars(form, vars), {
157
+ pending: true,
158
+ sourceLang: _state.sourceLang,
159
+ lang: _state.language,
160
+ });
134
161
  }
135
162
  // Aliases
136
163
  export const ait = t;
@@ -255,6 +282,7 @@ export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/ap
255
282
  headers: { 'Content-Type': 'application/json' },
256
283
  body: JSON.stringify({
257
284
  language: _state.language,
285
+ sourceLang: _state.sourceLang,
258
286
  strings,
259
287
  plurals,
260
288
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transduck",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
4
4
  "description": "AI-native translation tool using source text as keys",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/cli.ts CHANGED
@@ -743,7 +743,7 @@ export async function runStats(opts: StatsOptions): Promise<string> {
743
743
  // CLI entry point
744
744
  const program = new Command();
745
745
 
746
- program.name('transduck').description('AI-native translation tool').version('0.6.8');
746
+ program.name('transduck').description('AI-native translation tool').version('0.6.10');
747
747
 
748
748
  program.command('init')
749
749
  .description('Initialize a new transduck project')
package/src/handler.ts CHANGED
@@ -25,6 +25,7 @@ interface TranslationRequestPlural {
25
25
 
26
26
  export interface TranslationRequest {
27
27
  language: string;
28
+ sourceLang?: string;
28
29
  strings: TranslationRequestString[];
29
30
  plurals: TranslationRequestPlural[];
30
31
  }
@@ -77,6 +78,7 @@ export async function handleTranslationRequest(
77
78
  const store = await getStore(configPath);
78
79
  const shared = await getSharedStore(configPath);
79
80
  const targetLang = body.language.toUpperCase();
81
+ const bodySourceLang = body.sourceLang?.toUpperCase();
80
82
 
81
83
  const projectContextHash = hash(cfg.projectContext);
82
84
 
@@ -85,7 +87,7 @@ export async function handleTranslationRequest(
85
87
 
86
88
  // Translate regular strings
87
89
  for (const item of body.strings ?? []) {
88
- const sourceLang = item.sourceLang?.toUpperCase() ?? cfg.sourceLang;
90
+ const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
89
91
  const stringContextHash = hash(item.context ?? '');
90
92
  const key = `${item.text}||${item.context ?? ''}`;
91
93
  const lookupParams = {
@@ -148,7 +150,7 @@ export async function handleTranslationRequest(
148
150
 
149
151
  // Translate plurals
150
152
  for (const item of body.plurals ?? []) {
151
- const sourceLang = item.sourceLang?.toUpperCase() ?? cfg.sourceLang;
153
+ const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
152
154
  const stringContextHash = hash(item.context ?? '');
153
155
  const sourceKey = item.one + '\x00' + item.other;
154
156
  const responseKey = `${sourceKey}||${item.context ?? ''}`;
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import React, { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
4
+ import { TranslationResult } from '../result.js';
4
5
 
5
6
  // --- Module-level state (accessed by stable t()/ait() functions) ---
6
7
 
@@ -133,27 +134,40 @@ export function t(
133
134
  sourceText: string,
134
135
  context?: string,
135
136
  vars?: Record<string, string | number>,
136
- ): string {
137
+ ): TranslationResult {
137
138
  const key = `${sourceText}||${context ?? ''}`;
138
139
  _state.knownKeys.add(key);
139
140
 
140
141
  // Same language — return source
141
142
  if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
142
- return interpolateVars(sourceText, vars);
143
+ return new TranslationResult(interpolateVars(sourceText, vars), {
144
+ pending: false,
145
+ sourceLang: _state.sourceLang,
146
+ lang: _state.language,
147
+ });
143
148
  }
144
149
 
145
150
  // Cache hit
146
151
  const cached = _state.cache.get(key);
147
152
  if (cached !== undefined) {
148
- return interpolateVars(cached, vars);
153
+ return new TranslationResult(interpolateVars(cached, vars), {
154
+ pending: false,
155
+ sourceLang: _state.sourceLang,
156
+ lang: _state.language,
157
+ source: 'cache',
158
+ });
149
159
  }
150
160
 
151
161
  // Queue for fetch
152
162
  _state.pendingStrings.add(key);
153
163
  schedulePendingFlush();
154
164
 
155
- // Return source text as fallback
156
- return interpolateVars(sourceText, vars);
165
+ // Return source text as fallback, marked pending
166
+ return new TranslationResult(interpolateVars(sourceText, vars), {
167
+ pending: true,
168
+ sourceLang: _state.sourceLang,
169
+ lang: _state.language,
170
+ });
157
171
  }
158
172
 
159
173
  export function tPlural(
@@ -161,7 +175,7 @@ export function tPlural(
161
175
  other: string,
162
176
  count: number,
163
177
  opts?: { context?: string; vars?: Record<string, string | number> },
164
- ): string {
178
+ ): TranslationResult {
165
179
  const context = opts?.context;
166
180
  let vars: Record<string, string | number>;
167
181
  if (!opts?.vars) {
@@ -180,7 +194,11 @@ export function tPlural(
180
194
  if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
181
195
  const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
182
196
  const form = rules.select(count) === 'one' ? one : other;
183
- return interpolateVars(form, vars);
197
+ return new TranslationResult(interpolateVars(form, vars), {
198
+ pending: false,
199
+ sourceLang: _state.sourceLang,
200
+ lang: _state.language,
201
+ });
184
202
  }
185
203
 
186
204
  // Cache hit
@@ -189,17 +207,26 @@ export function tPlural(
189
207
  const rules = new Intl.PluralRules(_state.language.toLowerCase());
190
208
  const category = rules.select(count);
191
209
  const form = cachedForms[category] ?? cachedForms['other'] ?? other;
192
- return interpolateVars(form, vars);
210
+ return new TranslationResult(interpolateVars(form, vars), {
211
+ pending: false,
212
+ sourceLang: _state.sourceLang,
213
+ lang: _state.language,
214
+ source: 'cache',
215
+ });
193
216
  }
194
217
 
195
218
  // Queue for fetch
196
219
  _state.pendingPlurals.add(cacheKey);
197
220
  schedulePendingFlush();
198
221
 
199
- // Fallback to source form
222
+ // Fallback to source form, marked pending
200
223
  const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
201
224
  const form = rules.select(count) === 'one' ? one : other;
202
- return interpolateVars(form, vars);
225
+ return new TranslationResult(interpolateVars(form, vars), {
226
+ pending: true,
227
+ sourceLang: _state.sourceLang,
228
+ lang: _state.language,
229
+ });
203
230
  }
204
231
 
205
232
  // Aliases
@@ -355,6 +382,7 @@ export function TransDuckProvider({
355
382
  headers: { 'Content-Type': 'application/json' },
356
383
  body: JSON.stringify({
357
384
  language: _state.language,
385
+ sourceLang: _state.sourceLang,
358
386
  strings,
359
387
  plurals,
360
388
  }),
@@ -133,4 +133,54 @@ describe('handleTranslationRequest', () => {
133
133
  expect(result.plurals[pluralKey]).toBeDefined();
134
134
  expect(result.plurals[pluralKey].one).toBe('{count} Artikel');
135
135
  });
136
+
137
+ it('body-level sourceLang overrides config sourceLang', async () => {
138
+ const { TranslationStore } = await import('../src/storage.js');
139
+ const { createHash } = await import('crypto');
140
+ const hash = (t: string) => createHash('sha256').update(t).digest('hex');
141
+
142
+ // Cache entry stored under source FR (not config's EN)
143
+ const store = new TranslationStore(join(tmpDir, 'translations.db'));
144
+ await store.initialize();
145
+ await store.insert({
146
+ sourceText: 'Bonjour', sourceLang: 'FR', targetLang: 'DE',
147
+ projectContextHash: hash('A test site'), stringContextHash: hash(''),
148
+ translatedText: 'Hallo', model: 'gpt-4.1-mini', status: 'translated', stringContext: '',
149
+ });
150
+ store.close();
151
+
152
+ // With body-level sourceLang: FR, the handler should find the cache entry
153
+ const result = await handleTranslationRequest({
154
+ language: 'DE',
155
+ sourceLang: 'FR',
156
+ strings: [{ text: 'Bonjour' }],
157
+ plurals: [],
158
+ }, configPath);
159
+
160
+ expect(result.translations['Bonjour||']).toBe('Hallo');
161
+ });
162
+
163
+ it('per-item sourceLang overrides body-level sourceLang', async () => {
164
+ const { TranslationStore } = await import('../src/storage.js');
165
+ const { createHash } = await import('crypto');
166
+ const hash = (t: string) => createHash('sha256').update(t).digest('hex');
167
+
168
+ const store = new TranslationStore(join(tmpDir, 'translations.db'));
169
+ await store.initialize();
170
+ await store.insert({
171
+ sourceText: 'Hola', sourceLang: 'ES', targetLang: 'DE',
172
+ projectContextHash: hash('A test site'), stringContextHash: hash(''),
173
+ translatedText: 'Hallo', model: 'gpt-4.1-mini', status: 'translated', stringContext: '',
174
+ });
175
+ store.close();
176
+
177
+ const result = await handleTranslationRequest({
178
+ language: 'DE',
179
+ sourceLang: 'FR',
180
+ strings: [{ text: 'Hola', sourceLang: 'ES' }],
181
+ plurals: [],
182
+ }, configPath);
183
+
184
+ expect(result.translations['Hola||']).toBe('Hallo');
185
+ });
136
186
  });
@@ -3,6 +3,7 @@ import React from 'react';
3
3
  import { render, screen, cleanup, waitFor, act } from '@testing-library/react';
4
4
  import { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState, _getReactState } from '../src/react/index.js';
5
5
  import type { UseTransDuckReturn } from '../src/react/index.js';
6
+ import { TranslationResult } from '../src/result.js';
6
7
 
7
8
  // Mock fetch globally
8
9
  const mockFetch = vi.fn();
@@ -499,6 +500,162 @@ describe('TransDuckProvider + t()', () => {
499
500
  });
500
501
  });
501
502
 
503
+ it('t() returns a TranslationResult with pending=true on cache miss', () => {
504
+ mockFetch.mockResolvedValue({
505
+ ok: true,
506
+ json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
507
+ });
508
+
509
+ let captured: ReturnType<typeof t> | null = null;
510
+ function TestComp() {
511
+ useTransDuck();
512
+ captured = t('Hello');
513
+ return <span>{captured}</span>;
514
+ }
515
+
516
+ render(
517
+ <TransDuckProvider language="DE">
518
+ <TestComp />
519
+ </TransDuckProvider>
520
+ );
521
+
522
+ expect(captured).toBeInstanceOf(TranslationResult);
523
+ expect(captured!.pending).toBe(true);
524
+ expect(captured!.sourceLang).toBe('EN');
525
+ expect(captured!.lang).toBe('DE');
526
+ expect(captured!.toString()).toBe('Hello');
527
+ });
528
+
529
+ it('t() returns a TranslationResult with pending=false on cache hit', async () => {
530
+ mockFetch.mockResolvedValue({
531
+ ok: true,
532
+ json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
533
+ });
534
+
535
+ let captured: ReturnType<typeof t> | null = null;
536
+ function TestComp() {
537
+ useTransDuck();
538
+ captured = t('Hello');
539
+ return <span data-testid="text">{captured}</span>;
540
+ }
541
+
542
+ render(
543
+ <TransDuckProvider language="DE">
544
+ <TestComp />
545
+ </TransDuckProvider>
546
+ );
547
+
548
+ await waitFor(() => {
549
+ expect(screen.getByTestId('text').textContent).toBe('Hallo');
550
+ });
551
+
552
+ expect(captured).toBeInstanceOf(TranslationResult);
553
+ expect(captured!.pending).toBe(false);
554
+ expect(captured!.source).toBe('cache');
555
+ expect(captured!.toString()).toBe('Hallo');
556
+ });
557
+
558
+ it('t() returns a TranslationResult with pending=false when language matches source', () => {
559
+ let captured: ReturnType<typeof t> | null = null;
560
+ function TestComp() {
561
+ useTransDuck();
562
+ captured = t('Hello');
563
+ return <span>{captured}</span>;
564
+ }
565
+
566
+ render(
567
+ <TransDuckProvider language="EN" sourceLang="EN">
568
+ <TestComp />
569
+ </TransDuckProvider>
570
+ );
571
+
572
+ expect(captured).toBeInstanceOf(TranslationResult);
573
+ expect(captured!.pending).toBe(false);
574
+ expect(captured!.toString()).toBe('Hello');
575
+ });
576
+
577
+ it('tPlural() returns a TranslationResult with pending=true on cache miss', () => {
578
+ mockFetch.mockResolvedValue({
579
+ ok: true,
580
+ json: async () => ({ translations: {}, plurals: {} }),
581
+ });
582
+
583
+ let captured: ReturnType<typeof tPlural> | null = null;
584
+ function TestComp() {
585
+ useTransDuck();
586
+ captured = tPlural('{count} message', '{count} messages', 2);
587
+ return <span>{captured}</span>;
588
+ }
589
+
590
+ render(
591
+ <TransDuckProvider language="DE">
592
+ <TestComp />
593
+ </TransDuckProvider>
594
+ );
595
+
596
+ expect(captured).toBeInstanceOf(TranslationResult);
597
+ expect(captured!.pending).toBe(true);
598
+ expect(captured!.toString()).toBe('2 messages');
599
+ });
600
+
601
+ it('tPlural() returns a TranslationResult with pending=false on cache hit', async () => {
602
+ const pluralKey = '{count} message\x00{count} messages||';
603
+ mockFetch.mockResolvedValue({
604
+ ok: true,
605
+ json: async () => ({
606
+ translations: {},
607
+ plurals: { [pluralKey]: { one: '{count} Nachricht', other: '{count} Nachrichten' } },
608
+ }),
609
+ });
610
+
611
+ let captured: ReturnType<typeof tPlural> | null = null;
612
+ function TestComp() {
613
+ useTransDuck();
614
+ captured = tPlural('{count} message', '{count} messages', 3);
615
+ return <span data-testid="text">{captured}</span>;
616
+ }
617
+
618
+ render(
619
+ <TransDuckProvider language="DE">
620
+ <TestComp />
621
+ </TransDuckProvider>
622
+ );
623
+
624
+ await waitFor(() => {
625
+ expect(screen.getByTestId('text').textContent).toBe('3 Nachrichten');
626
+ });
627
+
628
+ expect(captured).toBeInstanceOf(TranslationResult);
629
+ expect(captured!.pending).toBe(false);
630
+ expect(captured!.source).toBe('cache');
631
+ });
632
+
633
+ it('fetch body includes sourceLang from provider prop', async () => {
634
+ mockFetch.mockResolvedValue({
635
+ ok: true,
636
+ json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
637
+ });
638
+
639
+ function TestComp() {
640
+ useTransDuck();
641
+ return <span data-testid="text">{t('Hello')}</span>;
642
+ }
643
+
644
+ render(
645
+ <TransDuckProvider language="DE" sourceLang="FR">
646
+ <TestComp />
647
+ </TransDuckProvider>
648
+ );
649
+
650
+ await waitFor(() => {
651
+ expect(mockFetch).toHaveBeenCalled();
652
+ });
653
+
654
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
655
+ expect(body.sourceLang).toBe('FR');
656
+ expect(body.language).toBe('DE');
657
+ });
658
+
502
659
  it('useTransDuck() still works without destructuring (backward compat)', async () => {
503
660
  mockFetch.mockResolvedValue({
504
661
  ok: true,