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 +1 -1
- package/dist/handler.js +2 -2
- package/dist/react/provider.d.ts +8 -3
- package/dist/react/provider.js +51 -22
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/handler.ts +2 -2
- package/src/react/provider.tsx +64 -22
- package/tests/handler.test.ts +10 -10
- package/tests/react-provider.test.tsx +204 -22
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.
|
|
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,
|
package/dist/react/provider.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|
package/dist/react/provider.js
CHANGED
|
@@ -77,24 +77,43 @@ function schedulePendingFlush() {
|
|
|
77
77
|
_state.triggerFetch?.();
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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() ===
|
|
86
|
-
return new TranslationResult(interpolateVars(sourceText,
|
|
104
|
+
if (_state.language.toUpperCase() === effectiveSourceLang) {
|
|
105
|
+
return new TranslationResult(interpolateVars(sourceText, resolvedVars), {
|
|
87
106
|
pending: false,
|
|
88
|
-
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,
|
|
114
|
+
return new TranslationResult(interpolateVars(cached, resolvedVars), {
|
|
96
115
|
pending: false,
|
|
97
|
-
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,
|
|
125
|
+
return new TranslationResult(interpolateVars(sourceText, resolvedVars), {
|
|
107
126
|
pending: true,
|
|
108
|
-
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 =
|
|
146
|
+
const cacheKey = buildKey(sourceKey, context, effectiveSourceLang);
|
|
126
147
|
_state.knownPlurals.add(cacheKey);
|
|
127
148
|
// Same language
|
|
128
|
-
if (_state.language.toUpperCase() ===
|
|
129
|
-
const rules = new Intl.PluralRules(
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
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
|
|
272
|
-
|
|
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
|
|
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
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.
|
|
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,
|
package/src/react/provider.tsx
CHANGED
|
@@ -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
|
-
|
|
152
|
+
contextOrOpts?: string | TOptions,
|
|
136
153
|
vars?: Record<string, string | number>,
|
|
137
154
|
): TranslationResult {
|
|
138
|
-
|
|
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() ===
|
|
143
|
-
return new TranslationResult(interpolateVars(sourceText,
|
|
173
|
+
if (_state.language.toUpperCase() === effectiveSourceLang) {
|
|
174
|
+
return new TranslationResult(interpolateVars(sourceText, resolvedVars), {
|
|
144
175
|
pending: false,
|
|
145
|
-
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,
|
|
184
|
+
return new TranslationResult(interpolateVars(cached, resolvedVars), {
|
|
154
185
|
pending: false,
|
|
155
|
-
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,
|
|
197
|
+
return new TranslationResult(interpolateVars(sourceText, resolvedVars), {
|
|
167
198
|
pending: true,
|
|
168
|
-
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?:
|
|
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 =
|
|
224
|
+
const cacheKey = buildKey(sourceKey, context, effectiveSourceLang);
|
|
191
225
|
_state.knownPlurals.add(cacheKey);
|
|
192
226
|
|
|
193
227
|
// Same language
|
|
194
|
-
if (_state.language.toUpperCase() ===
|
|
195
|
-
const rules = new Intl.PluralRules(
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
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
|
|
371
|
-
|
|
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
|
|
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 {
|
package/tests/handler.test.ts
CHANGED
|
@@ -63,7 +63,7 @@ describe('handleTranslationRequest', () => {
|
|
|
63
63
|
plurals: [],
|
|
64
64
|
}, configPath);
|
|
65
65
|
|
|
66
|
-
expect(result.translations['Hello
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
345
|
-
expect(result.translations['ok2
|
|
346
|
-
expect(result.translations['boom
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
466
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
|
|
467
467
|
});
|
|
468
468
|
mockFetch.mockResolvedValueOnce({
|
|
469
469
|
ok: true,
|
|
470
|
-
json: async () => ({ translations: { 'Hello
|
|
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
|
|
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
|
|
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
|
|
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
|
|
844
|
+
json: async () => ({ translations: { 'Hello||||EN': 'Hallo' }, plurals: {} }),
|
|
663
845
|
});
|
|
664
846
|
|
|
665
847
|
function TestComp() {
|