transduck 0.8.0 → 0.9.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/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.8.0');
640
+ program.name('transduck').description('AI-native translation tool').version('0.9.0');
641
641
  program.command('init')
642
642
  .description('Initialize a new transduck project')
643
643
  .action(async () => {
package/dist/handler.js CHANGED
@@ -94,7 +94,7 @@ export async function handleTranslationRequest(body, configPath, opts) {
94
94
  const stringWork = (body.strings ?? []).map(item => withSlot(sem, async () => {
95
95
  const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
96
96
  const stringContextHash = hash(item.context ?? '');
97
- const key = `${item.text}||${item.context ?? ''}`;
97
+ const key = `${item.text}||${item.context ?? ''}||${sourceLang}`;
98
98
  const lookupParams = {
99
99
  sourceText: item.text, sourceLang, targetLang,
100
100
  projectContextHash, stringContextHash,
@@ -146,7 +146,7 @@ export async function handleTranslationRequest(body, configPath, opts) {
146
146
  const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
147
147
  const stringContextHash = hash(item.context ?? '');
148
148
  const sourceKey = item.one + '\x00' + item.other;
149
- const responseKey = `${sourceKey}||${item.context ?? ''}`;
149
+ const responseKey = `${sourceKey}||${item.context ?? ''}||${sourceLang}`;
150
150
  const lookupParams = {
151
151
  sourceText: sourceKey, sourceLang, targetLang,
152
152
  projectContextHash, stringContextHash,
@@ -30,11 +30,16 @@ export interface UseTransDuckReturn {
30
30
  export declare function useTransDuck(): UseTransDuckReturn;
31
31
  export declare function _resetReactState(): void;
32
32
  export declare function _getReactState(): ReactState;
33
- export declare function t(sourceText: string, context?: string, vars?: Record<string, string | number>): TranslationResult;
34
- export declare function tPlural(one: string, other: string, count: number, opts?: {
33
+ export interface TOptions {
35
34
  context?: string;
36
35
  vars?: Record<string, string | number>;
37
- }): TranslationResult;
36
+ /** Override the provider's source language for this call. */
37
+ sourceLang?: string;
38
+ }
39
+ export interface TPluralOptions extends TOptions {
40
+ }
41
+ export declare function t(sourceText: string, contextOrOpts?: string | TOptions, vars?: Record<string, string | number>): TranslationResult;
42
+ export declare function tPlural(one: string, other: string, count: number, opts?: TPluralOptions): TranslationResult;
38
43
  export declare const ait: typeof t;
39
44
  export declare const aitPlural: typeof tPlural;
40
45
  interface TransDuckProviderProps {
@@ -77,24 +77,43 @@ function schedulePendingFlush() {
77
77
  _state.triggerFetch?.();
78
78
  });
79
79
  }
80
- // --- Stable exported functions ---
81
- export function t(sourceText, context, vars) {
82
- const key = `${sourceText}||${context ?? ''}`;
80
+ /**
81
+ * Build the client-side cache / pending-set key.
82
+ * Format: text || context || sourceLang (last two always present)
83
+ */
84
+ function buildKey(text, context, sourceLang) {
85
+ return `${text}||${context ?? ''}||${sourceLang}`;
86
+ }
87
+ export function t(sourceText, contextOrOpts, vars) {
88
+ let context;
89
+ let resolvedVars;
90
+ let sourceLangOverride;
91
+ if (typeof contextOrOpts === 'string' || contextOrOpts === undefined) {
92
+ context = contextOrOpts;
93
+ resolvedVars = vars;
94
+ }
95
+ else {
96
+ context = contextOrOpts.context;
97
+ resolvedVars = contextOrOpts.vars;
98
+ sourceLangOverride = contextOrOpts.sourceLang;
99
+ }
100
+ const effectiveSourceLang = (sourceLangOverride ?? _state.sourceLang).toUpperCase();
101
+ const key = buildKey(sourceText, context, effectiveSourceLang);
83
102
  _state.knownKeys.add(key);
84
103
  // Same language — return source
85
- if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
86
- return new TranslationResult(interpolateVars(sourceText, vars), {
104
+ if (_state.language.toUpperCase() === effectiveSourceLang) {
105
+ return new TranslationResult(interpolateVars(sourceText, resolvedVars), {
87
106
  pending: false,
88
- sourceLang: _state.sourceLang,
107
+ sourceLang: effectiveSourceLang,
89
108
  lang: _state.language,
90
109
  });
91
110
  }
92
111
  // Cache hit
93
112
  const cached = _state.cache.get(key);
94
113
  if (cached !== undefined) {
95
- return new TranslationResult(interpolateVars(cached, vars), {
114
+ return new TranslationResult(interpolateVars(cached, resolvedVars), {
96
115
  pending: false,
97
- sourceLang: _state.sourceLang,
116
+ sourceLang: effectiveSourceLang,
98
117
  lang: _state.language,
99
118
  source: 'cache',
100
119
  });
@@ -103,14 +122,16 @@ export function t(sourceText, context, vars) {
103
122
  _state.pendingStrings.add(key);
104
123
  schedulePendingFlush();
105
124
  // Return source text as fallback, marked pending
106
- return new TranslationResult(interpolateVars(sourceText, vars), {
125
+ return new TranslationResult(interpolateVars(sourceText, resolvedVars), {
107
126
  pending: true,
108
- sourceLang: _state.sourceLang,
127
+ sourceLang: effectiveSourceLang,
109
128
  lang: _state.language,
110
129
  });
111
130
  }
112
131
  export function tPlural(one, other, count, opts) {
113
132
  const context = opts?.context;
133
+ const sourceLangOverride = opts?.sourceLang;
134
+ const effectiveSourceLang = (sourceLangOverride ?? _state.sourceLang).toUpperCase();
114
135
  let vars;
115
136
  if (!opts?.vars) {
116
137
  vars = { count };
@@ -122,15 +143,15 @@ export function tPlural(one, other, count, opts) {
122
143
  vars = { ...opts.vars };
123
144
  }
124
145
  const sourceKey = `${one}\x00${other}`;
125
- const cacheKey = `${sourceKey}||${context ?? ''}`;
146
+ const cacheKey = buildKey(sourceKey, context, effectiveSourceLang);
126
147
  _state.knownPlurals.add(cacheKey);
127
148
  // Same language
128
- if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
129
- const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
149
+ if (_state.language.toUpperCase() === effectiveSourceLang) {
150
+ const rules = new Intl.PluralRules(effectiveSourceLang.toLowerCase());
130
151
  const form = rules.select(count) === 'one' ? one : other;
131
152
  return new TranslationResult(interpolateVars(form, vars), {
132
153
  pending: false,
133
- sourceLang: _state.sourceLang,
154
+ sourceLang: effectiveSourceLang,
134
155
  lang: _state.language,
135
156
  });
136
157
  }
@@ -142,7 +163,7 @@ export function tPlural(one, other, count, opts) {
142
163
  const form = cachedForms[category] ?? cachedForms['other'] ?? other;
143
164
  return new TranslationResult(interpolateVars(form, vars), {
144
165
  pending: false,
145
- sourceLang: _state.sourceLang,
166
+ sourceLang: effectiveSourceLang,
146
167
  lang: _state.language,
147
168
  source: 'cache',
148
169
  });
@@ -151,11 +172,11 @@ export function tPlural(one, other, count, opts) {
151
172
  _state.pendingPlurals.add(cacheKey);
152
173
  schedulePendingFlush();
153
174
  // Fallback to source form, marked pending
154
- const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
175
+ const rules = new Intl.PluralRules(effectiveSourceLang.toLowerCase());
155
176
  const form = rules.select(count) === 'one' ? one : other;
156
177
  return new TranslationResult(interpolateVars(form, vars), {
157
178
  pending: true,
158
- sourceLang: _state.sourceLang,
179
+ sourceLang: effectiveSourceLang,
159
180
  lang: _state.language,
160
181
  });
161
182
  }
@@ -266,15 +287,23 @@ export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/ap
266
287
  }
267
288
  return;
268
289
  }
269
- // Build request body
290
+ // Build request body.
291
+ // Key format: text || context || sourceLang. Pop the tail segments so text
292
+ // can contain "||" safely.
270
293
  const strings = Array.from(stringsToFetch).map(key => {
271
- const [text, context] = key.split('||');
272
- return { text, context: context || undefined };
294
+ const parts = key.split('||');
295
+ const sourceLang = parts.pop();
296
+ const context = parts.pop();
297
+ const text = parts.join('||');
298
+ return { text, context: context || undefined, sourceLang };
273
299
  });
274
300
  const plurals = Array.from(pluralsToFetch).map(key => {
275
- const [sourceKey, context] = key.split('||');
301
+ const parts = key.split('||');
302
+ const sourceLang = parts.pop();
303
+ const context = parts.pop();
304
+ const sourceKey = parts.join('||');
276
305
  const [one, other] = sourceKey.split('\x00');
277
- return { one, other, context: context || undefined };
306
+ return { one, other, context: context || undefined, sourceLang };
278
307
  });
279
308
  try {
280
309
  const response = await fetch(endpoint, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transduck",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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.8.0');
746
+ program.name('transduck').description('AI-native translation tool').version('0.9.0');
747
747
 
748
748
  program.command('init')
749
749
  .description('Initialize a new transduck project')
package/src/handler.ts CHANGED
@@ -132,7 +132,7 @@ export async function handleTranslationRequest(
132
132
  withSlot(sem, async (): Promise<[string, string]> => {
133
133
  const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
134
134
  const stringContextHash = hash(item.context ?? '');
135
- const key = `${item.text}||${item.context ?? ''}`;
135
+ const key = `${item.text}||${item.context ?? ''}||${sourceLang}`;
136
136
  const lookupParams = {
137
137
  sourceText: item.text, sourceLang, targetLang,
138
138
  projectContextHash, stringContextHash,
@@ -191,7 +191,7 @@ export async function handleTranslationRequest(
191
191
  const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
192
192
  const stringContextHash = hash(item.context ?? '');
193
193
  const sourceKey = item.one + '\x00' + item.other;
194
- const responseKey = `${sourceKey}||${item.context ?? ''}`;
194
+ const responseKey = `${sourceKey}||${item.context ?? ''}||${sourceLang}`;
195
195
  const lookupParams = {
196
196
  sourceText: sourceKey, sourceLang, targetLang,
197
197
  projectContextHash, stringContextHash,
@@ -130,19 +130,50 @@ function schedulePendingFlush() {
130
130
 
131
131
  // --- Stable exported functions ---
132
132
 
133
+ export interface TOptions {
134
+ context?: string;
135
+ vars?: Record<string, string | number>;
136
+ /** Override the provider's source language for this call. */
137
+ sourceLang?: string;
138
+ }
139
+
140
+ export interface TPluralOptions extends TOptions {}
141
+
142
+ /**
143
+ * Build the client-side cache / pending-set key.
144
+ * Format: text || context || sourceLang (last two always present)
145
+ */
146
+ function buildKey(text: string, context: string | undefined, sourceLang: string): string {
147
+ return `${text}||${context ?? ''}||${sourceLang}`;
148
+ }
149
+
133
150
  export function t(
134
151
  sourceText: string,
135
- context?: string,
152
+ contextOrOpts?: string | TOptions,
136
153
  vars?: Record<string, string | number>,
137
154
  ): TranslationResult {
138
- const key = `${sourceText}||${context ?? ''}`;
155
+ let context: string | undefined;
156
+ let resolvedVars: Record<string, string | number> | undefined;
157
+ let sourceLangOverride: string | undefined;
158
+
159
+ if (typeof contextOrOpts === 'string' || contextOrOpts === undefined) {
160
+ context = contextOrOpts;
161
+ resolvedVars = vars;
162
+ } else {
163
+ context = contextOrOpts.context;
164
+ resolvedVars = contextOrOpts.vars;
165
+ sourceLangOverride = contextOrOpts.sourceLang;
166
+ }
167
+
168
+ const effectiveSourceLang = (sourceLangOverride ?? _state.sourceLang).toUpperCase();
169
+ const key = buildKey(sourceText, context, effectiveSourceLang);
139
170
  _state.knownKeys.add(key);
140
171
 
141
172
  // Same language — return source
142
- if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
143
- return new TranslationResult(interpolateVars(sourceText, vars), {
173
+ if (_state.language.toUpperCase() === effectiveSourceLang) {
174
+ return new TranslationResult(interpolateVars(sourceText, resolvedVars), {
144
175
  pending: false,
145
- sourceLang: _state.sourceLang,
176
+ sourceLang: effectiveSourceLang,
146
177
  lang: _state.language,
147
178
  });
148
179
  }
@@ -150,9 +181,9 @@ export function t(
150
181
  // Cache hit
151
182
  const cached = _state.cache.get(key);
152
183
  if (cached !== undefined) {
153
- return new TranslationResult(interpolateVars(cached, vars), {
184
+ return new TranslationResult(interpolateVars(cached, resolvedVars), {
154
185
  pending: false,
155
- sourceLang: _state.sourceLang,
186
+ sourceLang: effectiveSourceLang,
156
187
  lang: _state.language,
157
188
  source: 'cache',
158
189
  });
@@ -163,9 +194,9 @@ export function t(
163
194
  schedulePendingFlush();
164
195
 
165
196
  // Return source text as fallback, marked pending
166
- return new TranslationResult(interpolateVars(sourceText, vars), {
197
+ return new TranslationResult(interpolateVars(sourceText, resolvedVars), {
167
198
  pending: true,
168
- sourceLang: _state.sourceLang,
199
+ sourceLang: effectiveSourceLang,
169
200
  lang: _state.language,
170
201
  });
171
202
  }
@@ -174,9 +205,12 @@ export function tPlural(
174
205
  one: string,
175
206
  other: string,
176
207
  count: number,
177
- opts?: { context?: string; vars?: Record<string, string | number> },
208
+ opts?: TPluralOptions,
178
209
  ): TranslationResult {
179
210
  const context = opts?.context;
211
+ const sourceLangOverride = opts?.sourceLang;
212
+ const effectiveSourceLang = (sourceLangOverride ?? _state.sourceLang).toUpperCase();
213
+
180
214
  let vars: Record<string, string | number>;
181
215
  if (!opts?.vars) {
182
216
  vars = { count };
@@ -187,16 +221,16 @@ export function tPlural(
187
221
  }
188
222
 
189
223
  const sourceKey = `${one}\x00${other}`;
190
- const cacheKey = `${sourceKey}||${context ?? ''}`;
224
+ const cacheKey = buildKey(sourceKey, context, effectiveSourceLang);
191
225
  _state.knownPlurals.add(cacheKey);
192
226
 
193
227
  // Same language
194
- if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
195
- const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
228
+ if (_state.language.toUpperCase() === effectiveSourceLang) {
229
+ const rules = new Intl.PluralRules(effectiveSourceLang.toLowerCase());
196
230
  const form = rules.select(count) === 'one' ? one : other;
197
231
  return new TranslationResult(interpolateVars(form, vars), {
198
232
  pending: false,
199
- sourceLang: _state.sourceLang,
233
+ sourceLang: effectiveSourceLang,
200
234
  lang: _state.language,
201
235
  });
202
236
  }
@@ -209,7 +243,7 @@ export function tPlural(
209
243
  const form = cachedForms[category] ?? cachedForms['other'] ?? other;
210
244
  return new TranslationResult(interpolateVars(form, vars), {
211
245
  pending: false,
212
- sourceLang: _state.sourceLang,
246
+ sourceLang: effectiveSourceLang,
213
247
  lang: _state.language,
214
248
  source: 'cache',
215
249
  });
@@ -220,11 +254,11 @@ export function tPlural(
220
254
  schedulePendingFlush();
221
255
 
222
256
  // Fallback to source form, marked pending
223
- const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
257
+ const rules = new Intl.PluralRules(effectiveSourceLang.toLowerCase());
224
258
  const form = rules.select(count) === 'one' ? one : other;
225
259
  return new TranslationResult(interpolateVars(form, vars), {
226
260
  pending: true,
227
- sourceLang: _state.sourceLang,
261
+ sourceLang: effectiveSourceLang,
228
262
  lang: _state.language,
229
263
  });
230
264
  }
@@ -365,15 +399,23 @@ export function TransDuckProvider({
365
399
  return;
366
400
  }
367
401
 
368
- // Build request body
402
+ // Build request body.
403
+ // Key format: text || context || sourceLang. Pop the tail segments so text
404
+ // can contain "||" safely.
369
405
  const strings = Array.from(stringsToFetch).map(key => {
370
- const [text, context] = key.split('||');
371
- return { text, context: context || undefined };
406
+ const parts = key.split('||');
407
+ const sourceLang = parts.pop()!;
408
+ const context = parts.pop();
409
+ const text = parts.join('||');
410
+ return { text, context: context || undefined, sourceLang };
372
411
  });
373
412
  const plurals = Array.from(pluralsToFetch).map(key => {
374
- const [sourceKey, context] = key.split('||');
413
+ const parts = key.split('||');
414
+ const sourceLang = parts.pop()!;
415
+ const context = parts.pop();
416
+ const sourceKey = parts.join('||');
375
417
  const [one, other] = sourceKey.split('\x00');
376
- return { one, other, context: context || undefined };
418
+ return { one, other, context: context || undefined, sourceLang };
377
419
  });
378
420
 
379
421
  try {
@@ -63,7 +63,7 @@ describe('handleTranslationRequest', () => {
63
63
  plurals: [],
64
64
  }, configPath);
65
65
 
66
- expect(result.translations['Hello||']).toBe('Hallo');
66
+ expect(result.translations['Hello||||EN']).toBe('Hallo');
67
67
  });
68
68
 
69
69
  it('returns empty translations for uncached strings without API key', async () => {
@@ -75,7 +75,7 @@ describe('handleTranslationRequest', () => {
75
75
  }, configPath);
76
76
 
77
77
  // Without API key, backend will fail, should return source text
78
- expect(result.translations['Unknown||']).toBe('Unknown');
78
+ expect(result.translations['Unknown||||EN']).toBe('Unknown');
79
79
  });
80
80
 
81
81
  it('handles context in cache key', async () => {
@@ -98,7 +98,7 @@ describe('handleTranslationRequest', () => {
98
98
  plurals: [],
99
99
  }, configPath);
100
100
 
101
- expect(result.translations['Book||Hotel booking']).toBe('Buchen');
101
+ expect(result.translations['Book||Hotel booking||EN']).toBe('Buchen');
102
102
  });
103
103
 
104
104
  it('returns plural forms from cache', async () => {
@@ -129,7 +129,7 @@ describe('handleTranslationRequest', () => {
129
129
  plurals: [{ one: '{count} item', other: '{count} items' }],
130
130
  }, configPath);
131
131
 
132
- const pluralKey = '{count} item\x00{count} items||';
132
+ const pluralKey = '{count} item\x00{count} items||||EN';
133
133
  expect(result.plurals[pluralKey]).toBeDefined();
134
134
  expect(result.plurals[pluralKey].one).toBe('{count} Artikel');
135
135
  });
@@ -157,7 +157,7 @@ describe('handleTranslationRequest', () => {
157
157
  plurals: [],
158
158
  }, configPath);
159
159
 
160
- expect(result.translations['Bonjour||']).toBe('Hallo');
160
+ expect(result.translations['Bonjour||||FR']).toBe('Hallo');
161
161
  });
162
162
 
163
163
  it('per-item sourceLang overrides body-level sourceLang', async () => {
@@ -181,7 +181,7 @@ describe('handleTranslationRequest', () => {
181
181
  plurals: [],
182
182
  }, configPath);
183
183
 
184
- expect(result.translations['Hola||']).toBe('Hallo');
184
+ expect(result.translations['Hola||||ES']).toBe('Hallo');
185
185
  });
186
186
  });
187
187
 
@@ -281,7 +281,7 @@ backend:
281
281
  expect(peak).toBe(5);
282
282
  expect(Object.keys(result.translations)).toHaveLength(20);
283
283
  for (let i = 0; i < 20; i++) {
284
- expect(result.translations[`s${i}||`]).toBe(`translated-s${i}`);
284
+ expect(result.translations[`s${i}||||EN`]).toBe(`translated-s${i}`);
285
285
  }
286
286
  vi.restoreAllMocks();
287
287
  });
@@ -341,9 +341,9 @@ backend:
341
341
  configPath,
342
342
  );
343
343
 
344
- expect(result.translations['ok1||']).toBe('t-ok1');
345
- expect(result.translations['ok2||']).toBe('t-ok2');
346
- expect(result.translations['boom||']).toBe('boom'); // fallback to source
344
+ expect(result.translations['ok1||||EN']).toBe('t-ok1');
345
+ expect(result.translations['ok2||||EN']).toBe('t-ok2');
346
+ expect(result.translations['boom||||EN']).toBe('boom'); // fallback to source
347
347
  vi.restoreAllMocks();
348
348
  });
349
349
  });
@@ -33,7 +33,7 @@ describe('TransDuckProvider + t()', () => {
33
33
  it('t() returns source text before translation loads', () => {
34
34
  mockFetch.mockResolvedValue({
35
35
  ok: true,
36
- json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
36
+ json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
37
37
  });
38
38
 
39
39
  function TestComp() {
@@ -94,7 +94,7 @@ describe('TransDuckProvider + t()', () => {
94
94
  mockFetch.mockResolvedValue({
95
95
  ok: true,
96
96
  json: async () => ({
97
- translations: { 'Hello||': 'Hallo', 'World||': 'Welt' },
97
+ translations: { 'Hello||||EN': 'Hallo', 'World||||EN': 'Welt' },
98
98
  plurals: {},
99
99
  }),
100
100
  });
@@ -129,7 +129,7 @@ describe('TransDuckProvider + t()', () => {
129
129
  mockFetch.mockResolvedValue({
130
130
  ok: true,
131
131
  json: async () => ({
132
- translations: { 'Hello||': 'Hallo' },
132
+ translations: { 'Hello||||EN': 'Hallo' },
133
133
  plurals: {},
134
134
  }),
135
135
  });
@@ -165,7 +165,7 @@ describe('TransDuckProvider + t()', () => {
165
165
  it('t() tracks called keys in _state.knownKeys', async () => {
166
166
  mockFetch.mockResolvedValue({
167
167
  ok: true,
168
- json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
168
+ json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
169
169
  });
170
170
 
171
171
  function TestComp() {
@@ -180,7 +180,7 @@ describe('TransDuckProvider + t()', () => {
180
180
  );
181
181
 
182
182
  const state = _getReactState();
183
- expect(state.knownKeys.has('Hello||')).toBe(true);
183
+ expect(state.knownKeys.has('Hello||||EN')).toBe(true);
184
184
  });
185
185
 
186
186
  it('useTransDuck() returns t, tPlural, ait, aitPlural, language, setLanguage, isLoading', () => {
@@ -211,12 +211,12 @@ describe('TransDuckProvider + t()', () => {
211
211
  // First render in DE
212
212
  mockFetch.mockResolvedValueOnce({
213
213
  ok: true,
214
- json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
214
+ json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
215
215
  });
216
216
  // After switch to ES
217
217
  mockFetch.mockResolvedValueOnce({
218
218
  ok: true,
219
- json: async () => ({ translations: { 'Hello||': 'Hola' }, plurals: {} }),
219
+ json: async () => ({ translations: { 'Hello||||EN': 'Hola' }, plurals: {} }),
220
220
  });
221
221
 
222
222
  let hookRef: UseTransDuckReturn | null = null;
@@ -254,7 +254,7 @@ describe('TransDuckProvider + t()', () => {
254
254
  it('setLanguage() with current language is a no-op', async () => {
255
255
  mockFetch.mockResolvedValueOnce({
256
256
  ok: true,
257
- json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
257
+ json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
258
258
  });
259
259
 
260
260
  let hookRef: UseTransDuckReturn | null = null;
@@ -288,7 +288,7 @@ describe('TransDuckProvider + t()', () => {
288
288
  it('setLanguage() to source language skips fetch', async () => {
289
289
  mockFetch.mockResolvedValueOnce({
290
290
  ok: true,
291
- json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
291
+ json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
292
292
  });
293
293
 
294
294
  let hookRef: UseTransDuckReturn | null = null;
@@ -324,7 +324,7 @@ describe('TransDuckProvider + t()', () => {
324
324
  it('isLoading is true during language switch fetch', async () => {
325
325
  mockFetch.mockResolvedValueOnce({
326
326
  ok: true,
327
- json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
327
+ json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
328
328
  });
329
329
 
330
330
  let resolveSecondFetch: ((value: unknown) => void) | null = null;
@@ -359,7 +359,7 @@ describe('TransDuckProvider + t()', () => {
359
359
  await act(async () => {
360
360
  resolveSecondFetch!({
361
361
  ok: true,
362
- json: async () => ({ translations: { 'Hello||': 'Hola' }, plurals: {} }),
362
+ json: async () => ({ translations: { 'Hello||||EN': 'Hola' }, plurals: {} }),
363
363
  });
364
364
  });
365
365
 
@@ -372,7 +372,7 @@ describe('TransDuckProvider + t()', () => {
372
372
  it('isLoading clears on fetch error during language switch', async () => {
373
373
  mockFetch.mockResolvedValueOnce({
374
374
  ok: true,
375
- json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
375
+ json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
376
376
  });
377
377
  mockFetch.mockRejectedValueOnce(new Error('Network error'));
378
378
 
@@ -406,7 +406,7 @@ describe('TransDuckProvider + t()', () => {
406
406
  it('discards stale fetch response when language changes during fetch', async () => {
407
407
  mockFetch.mockResolvedValueOnce({
408
408
  ok: true,
409
- json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
409
+ json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
410
410
  });
411
411
 
412
412
  let resolveFRFetch: ((value: unknown) => void) | null = null;
@@ -416,7 +416,7 @@ describe('TransDuckProvider + t()', () => {
416
416
 
417
417
  mockFetch.mockResolvedValueOnce({
418
418
  ok: true,
419
- json: async () => ({ translations: { 'Hello||': 'Hola' }, plurals: {} }),
419
+ json: async () => ({ translations: { 'Hello||||EN': 'Hola' }, plurals: {} }),
420
420
  });
421
421
 
422
422
  let hookRef: UseTransDuckReturn | null = null;
@@ -452,7 +452,7 @@ describe('TransDuckProvider + t()', () => {
452
452
  await act(async () => {
453
453
  resolveFRFetch!({
454
454
  ok: true,
455
- json: async () => ({ translations: { 'Hello||': 'Bonjour' }, plurals: {} }),
455
+ json: async () => ({ translations: { 'Hello||||EN': 'Bonjour' }, plurals: {} }),
456
456
  });
457
457
  });
458
458
 
@@ -463,11 +463,11 @@ describe('TransDuckProvider + t()', () => {
463
463
  it('changing language prop triggers switchLanguage with isLoading', async () => {
464
464
  mockFetch.mockResolvedValueOnce({
465
465
  ok: true,
466
- json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
466
+ json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
467
467
  });
468
468
  mockFetch.mockResolvedValueOnce({
469
469
  ok: true,
470
- json: async () => ({ translations: { 'Hello||': 'Hola' }, plurals: {} }),
470
+ json: async () => ({ translations: { 'Hello||||EN': 'Hola' }, plurals: {} }),
471
471
  });
472
472
 
473
473
  let hookRef: UseTransDuckReturn | null = null;
@@ -503,7 +503,7 @@ describe('TransDuckProvider + t()', () => {
503
503
  it('t() returns a TranslationResult with pending=true on cache miss', () => {
504
504
  mockFetch.mockResolvedValue({
505
505
  ok: true,
506
- json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
506
+ json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
507
507
  });
508
508
 
509
509
  let captured: ReturnType<typeof t> | null = null;
@@ -529,7 +529,7 @@ describe('TransDuckProvider + t()', () => {
529
529
  it('t() returns a TranslationResult with pending=false on cache hit', async () => {
530
530
  mockFetch.mockResolvedValue({
531
531
  ok: true,
532
- json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
532
+ json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
533
533
  });
534
534
 
535
535
  let captured: ReturnType<typeof t> | null = null;
@@ -599,7 +599,7 @@ describe('TransDuckProvider + t()', () => {
599
599
  });
600
600
 
601
601
  it('tPlural() returns a TranslationResult with pending=false on cache hit', async () => {
602
- const pluralKey = '{count} message\x00{count} messages||';
602
+ const pluralKey = '{count} message\x00{count} messages||||EN';
603
603
  mockFetch.mockResolvedValue({
604
604
  ok: true,
605
605
  json: async () => ({
@@ -633,7 +633,7 @@ describe('TransDuckProvider + t()', () => {
633
633
  it('fetch body includes sourceLang from provider prop', async () => {
634
634
  mockFetch.mockResolvedValue({
635
635
  ok: true,
636
- json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
636
+ json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
637
637
  });
638
638
 
639
639
  function TestComp() {
@@ -656,10 +656,192 @@ describe('TransDuckProvider + t()', () => {
656
656
  expect(body.language).toBe('DE');
657
657
  });
658
658
 
659
+ it('t() with per-call sourceLang option uses it instead of provider sourceLang', async () => {
660
+ // POS case: menu items in ES, allergens in EN, user target DE.
661
+ // Both t() calls run in the same <TransDuckProvider sourceLang="ES">.
662
+ mockFetch.mockResolvedValue({
663
+ ok: true,
664
+ json: async () => ({
665
+ translations: {
666
+ 'Gambas||||ES': 'Garnelen',
667
+ 'Contains nuts||||EN': 'Enthält Nüsse',
668
+ },
669
+ plurals: {},
670
+ }),
671
+ });
672
+
673
+ function TestComp() {
674
+ useTransDuck();
675
+ return (
676
+ <div>
677
+ <span data-testid="dish">{t('Gambas')}</span>
678
+ <span data-testid="allergen">{t('Contains nuts', { sourceLang: 'EN' })}</span>
679
+ </div>
680
+ );
681
+ }
682
+
683
+ render(
684
+ <TransDuckProvider language="DE" sourceLang="ES">
685
+ <TestComp />
686
+ </TransDuckProvider>
687
+ );
688
+
689
+ await waitFor(() => {
690
+ expect(screen.getByTestId('dish').textContent).toBe('Garnelen');
691
+ expect(screen.getByTestId('allergen').textContent).toBe('Enthält Nüsse');
692
+ });
693
+
694
+ // Fetch body contains per-item sourceLang for the allergen.
695
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
696
+ const dish = body.strings.find((s: any) => s.text === 'Gambas');
697
+ const allergen = body.strings.find((s: any) => s.text === 'Contains nuts');
698
+ expect(dish.sourceLang).toBe('ES');
699
+ expect(allergen.sourceLang).toBe('EN');
700
+ });
701
+
702
+ it('t() same text under different sourceLang does not collide in cache', async () => {
703
+ // Two menu entries with identical source text but different source langs
704
+ // must resolve to distinct cache entries and independent translations.
705
+ mockFetch.mockResolvedValue({
706
+ ok: true,
707
+ json: async () => ({
708
+ translations: {
709
+ 'Salat||||DE': 'Salad', // German menu item → English
710
+ 'Salat||||NL': 'Lettuce', // Dutch menu item → English
711
+ },
712
+ plurals: {},
713
+ }),
714
+ });
715
+
716
+ function TestComp() {
717
+ useTransDuck();
718
+ return (
719
+ <div>
720
+ <span data-testid="de">{t('Salat', { sourceLang: 'DE' })}</span>
721
+ <span data-testid="nl">{t('Salat', { sourceLang: 'NL' })}</span>
722
+ </div>
723
+ );
724
+ }
725
+
726
+ render(
727
+ <TransDuckProvider language="EN" sourceLang="EN">
728
+ <TestComp />
729
+ </TransDuckProvider>
730
+ );
731
+
732
+ await waitFor(() => {
733
+ expect(screen.getByTestId('de').textContent).toBe('Salad');
734
+ expect(screen.getByTestId('nl').textContent).toBe('Lettuce');
735
+ });
736
+ });
737
+
738
+ it('t() same-language short-circuit respects per-call sourceLang', () => {
739
+ // Provider is ES→DE, but this call marks its text as EN source.
740
+ // Target language is DE, so this translation IS needed even though
741
+ // provider.sourceLang === 'ES'. No same-language short-circuit.
742
+ let captured: ReturnType<typeof t> | null = null;
743
+ function TestComp() {
744
+ useTransDuck();
745
+ captured = t('Contains nuts', { sourceLang: 'EN' });
746
+ return <span>{captured}</span>;
747
+ }
748
+
749
+ render(
750
+ <TransDuckProvider language="DE" sourceLang="ES">
751
+ <TestComp />
752
+ </TransDuckProvider>
753
+ );
754
+
755
+ expect(captured!.pending).toBe(true);
756
+ expect(captured!.sourceLang).toBe('EN');
757
+ });
758
+
759
+ it('t() short-circuits when per-call sourceLang equals target language', () => {
760
+ // Provider ES→EN. Allergen is EN source, target is EN: no translation.
761
+ let captured: ReturnType<typeof t> | null = null;
762
+ function TestComp() {
763
+ useTransDuck();
764
+ captured = t('Contains nuts', { sourceLang: 'EN' });
765
+ return <span>{captured}</span>;
766
+ }
767
+
768
+ render(
769
+ <TransDuckProvider language="EN" sourceLang="ES">
770
+ <TestComp />
771
+ </TransDuckProvider>
772
+ );
773
+
774
+ expect(captured!.pending).toBe(false);
775
+ expect(captured!.toString()).toBe('Contains nuts');
776
+ });
777
+
778
+ it('t() options form supports context and vars', async () => {
779
+ mockFetch.mockResolvedValue({
780
+ ok: true,
781
+ json: async () => ({
782
+ translations: { 'Book||hotel booking||EN': 'Buchen {name}' },
783
+ plurals: {},
784
+ }),
785
+ });
786
+
787
+ function TestComp() {
788
+ useTransDuck();
789
+ return (
790
+ <span data-testid="t">
791
+ {t('Book', { context: 'hotel booking', vars: { name: 'Tim' }, sourceLang: 'EN' })}
792
+ </span>
793
+ );
794
+ }
795
+
796
+ render(
797
+ <TransDuckProvider language="DE" sourceLang="EN">
798
+ <TestComp />
799
+ </TransDuckProvider>
800
+ );
801
+
802
+ await waitFor(() => {
803
+ expect(screen.getByTestId('t').textContent).toBe('Buchen Tim');
804
+ });
805
+ });
806
+
807
+ it('tPlural() accepts per-call sourceLang', async () => {
808
+ const pluralKey = '{count} item\x00{count} items||||EN';
809
+ mockFetch.mockResolvedValue({
810
+ ok: true,
811
+ json: async () => ({
812
+ translations: {},
813
+ plurals: { [pluralKey]: { one: '{count} Artikel', other: '{count} Artikel' } },
814
+ }),
815
+ });
816
+
817
+ function TestComp() {
818
+ useTransDuck();
819
+ return (
820
+ <span data-testid="p">
821
+ {tPlural('{count} item', '{count} items', 5, { sourceLang: 'EN' })}
822
+ </span>
823
+ );
824
+ }
825
+
826
+ // Provider source is ES, but this plural is EN source.
827
+ render(
828
+ <TransDuckProvider language="DE" sourceLang="ES">
829
+ <TestComp />
830
+ </TransDuckProvider>
831
+ );
832
+
833
+ await waitFor(() => {
834
+ expect(screen.getByTestId('p').textContent).toBe('5 Artikel');
835
+ });
836
+
837
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
838
+ expect(body.plurals[0].sourceLang).toBe('EN');
839
+ });
840
+
659
841
  it('useTransDuck() still works without destructuring (backward compat)', async () => {
660
842
  mockFetch.mockResolvedValue({
661
843
  ok: true,
662
- json: async () => ({ translations: { 'Hello||': 'Hallo' }, plurals: {} }),
844
+ json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
663
845
  });
664
846
 
665
847
  function TestComp() {