transduck 0.6.7 → 0.6.9

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
@@ -376,6 +376,19 @@ export async function runScan(opts) {
376
376
  const store = new TranslationStore(cfg.storagePath);
377
377
  await store.initialize();
378
378
  const projectContextHash = hash(cfg.projectContext);
379
+ // Initialize shared Postgres store if configured
380
+ let shared = null;
381
+ if (cfg.sharedUrl) {
382
+ try {
383
+ shared = new SharedStore(cfg.sharedUrl);
384
+ await shared.initialize();
385
+ console.log(' Connected to shared Postgres store');
386
+ }
387
+ catch (err) {
388
+ console.warn(` Warning: could not connect to shared store: ${err.message}`);
389
+ shared = null;
390
+ }
391
+ }
379
392
  let translated = 0;
380
393
  let skipped = 0;
381
394
  let failed = 0;
@@ -386,26 +399,68 @@ export async function runScan(opts) {
386
399
  const sourceKey = entry.one + '\x00' + entry.other;
387
400
  const stringContextHash = hash(entry.context ?? '');
388
401
  const label = (entry.one ?? '').slice(0, 40);
402
+ const lookupParams = {
403
+ sourceText: sourceKey, sourceLang: cfg.sourceLang,
404
+ projectContextHash, stringContextHash,
405
+ };
389
406
  for (const lang of targetLangs) {
390
407
  done++;
391
- const cachedForms = await store.lookupPlural({
392
- sourceText: sourceKey, sourceLang: cfg.sourceLang, targetLang: lang,
393
- projectContextHash, stringContextHash,
394
- });
408
+ const langLookup = { ...lookupParams, targetLang: lang };
409
+ const cachedForms = await store.lookupPlural(langLookup);
395
410
  if (Object.keys(cachedForms).length > 0) {
411
+ // Ensure shared store has it too
412
+ if (shared) {
413
+ try {
414
+ const sharedForms = await shared.lookupPlural(langLookup);
415
+ if (Object.keys(sharedForms).length === 0) {
416
+ for (const [cat, text] of Object.entries(cachedForms)) {
417
+ await shared.insertPlural({
418
+ ...langLookup, stringContext: entry.context ?? '',
419
+ pluralCategory: cat, translatedText: text,
420
+ model: cfg.backendModel, status: 'translated',
421
+ });
422
+ }
423
+ }
424
+ }
425
+ catch { /* ignore shared store errors */ }
426
+ }
396
427
  skipped++;
397
428
  console.log(` [${done}/${total}] ${lang} skipped (cached): ${label}`);
398
429
  continue;
399
430
  }
431
+ // Check shared store before calling backend
432
+ if (shared) {
433
+ try {
434
+ const sharedForms = await shared.lookupPlural(langLookup);
435
+ if (Object.keys(sharedForms).length > 0) {
436
+ for (const [cat, text] of Object.entries(sharedForms)) {
437
+ await store.insertPlural({
438
+ ...langLookup, stringContext: entry.context ?? '',
439
+ pluralCategory: cat, translatedText: text,
440
+ model: cfg.backendModel, status: 'translated',
441
+ });
442
+ }
443
+ skipped++;
444
+ console.log(` [${done}/${total}] ${lang} skipped (shared cache): ${label}`);
445
+ continue;
446
+ }
447
+ }
448
+ catch { /* ignore */ }
449
+ }
400
450
  try {
401
451
  const forms = await backendTranslatePlural(entry.one, entry.other, cfg.sourceLang, lang, cfg.projectContext, entry.context ?? null, cfg);
402
452
  for (const [cat, translatedText] of Object.entries(forms)) {
403
- await store.insertPlural({
404
- sourceText: sourceKey, sourceLang: cfg.sourceLang, targetLang: lang,
405
- projectContextHash, stringContextHash, stringContext: entry.context ?? '',
453
+ const insertParams = {
454
+ ...langLookup, stringContext: entry.context ?? '',
406
455
  pluralCategory: cat, translatedText: translatedText,
407
456
  model: cfg.backendModel, status: 'translated',
408
- });
457
+ };
458
+ await store.insertPlural(insertParams);
459
+ if (shared)
460
+ try {
461
+ await shared.insertPlural(insertParams);
462
+ }
463
+ catch { /* ignore */ }
409
464
  }
410
465
  translated++;
411
466
  console.log(` [${done}/${total}] ${lang} translated: ${label}`);
@@ -419,25 +474,61 @@ export async function runScan(opts) {
419
474
  else {
420
475
  const stringContextHash = hash(entry.context ?? '');
421
476
  const label = (entry.text ?? '').slice(0, 40);
477
+ const lookupParams = {
478
+ sourceText: entry.text, sourceLang: cfg.sourceLang,
479
+ projectContextHash, stringContextHash,
480
+ };
422
481
  for (const lang of targetLangs) {
423
482
  done++;
424
- const cached = await store.lookup({
425
- sourceText: entry.text, sourceLang: cfg.sourceLang, targetLang: lang,
426
- projectContextHash, stringContextHash,
427
- });
483
+ const langLookup = { ...lookupParams, targetLang: lang };
484
+ const cached = await store.lookup(langLookup);
428
485
  if (cached !== null) {
486
+ // Ensure shared store has it too
487
+ if (shared) {
488
+ try {
489
+ const sharedCached = await shared.lookup(langLookup);
490
+ if (sharedCached === null) {
491
+ await shared.insert({
492
+ ...langLookup, stringContext: entry.context ?? '',
493
+ translatedText: cached, model: cfg.backendModel, status: 'translated',
494
+ });
495
+ }
496
+ }
497
+ catch { /* ignore */ }
498
+ }
429
499
  skipped++;
430
500
  console.log(` [${done}/${total}] ${lang} skipped (cached): ${label}`);
431
501
  continue;
432
502
  }
503
+ // Check shared store before calling backend
504
+ if (shared) {
505
+ try {
506
+ const sharedCached = await shared.lookup(langLookup);
507
+ if (sharedCached !== null) {
508
+ await store.insert({
509
+ ...langLookup, stringContext: entry.context ?? '',
510
+ translatedText: sharedCached, model: cfg.backendModel, status: 'translated',
511
+ });
512
+ skipped++;
513
+ console.log(` [${done}/${total}] ${lang} skipped (shared cache): ${label}`);
514
+ continue;
515
+ }
516
+ }
517
+ catch { /* ignore */ }
518
+ }
433
519
  try {
434
520
  const result = await backendTranslate(entry.text, cfg.sourceLang, lang, cfg.projectContext, entry.context ?? null, cfg);
435
521
  if (validateTranslation(entry.text, result)) {
436
- await store.insert({
437
- sourceText: entry.text, sourceLang: cfg.sourceLang, targetLang: lang,
438
- projectContextHash, stringContextHash, stringContext: entry.context ?? '',
522
+ const insertParams = {
523
+ ...langLookup, stringContext: entry.context ?? '',
439
524
  translatedText: result, model: cfg.backendModel, status: 'translated',
440
- });
525
+ };
526
+ await store.insert(insertParams);
527
+ if (shared)
528
+ try {
529
+ await shared.insert(insertParams);
530
+ }
531
+ catch { /* ignore */ }
441
532
  translated++;
442
533
  console.log(` [${done}/${total}] ${lang} translated: ${label}`);
443
534
  }
@@ -454,6 +545,8 @@ export async function runScan(opts) {
454
545
  }
455
546
  }
456
547
  store.close();
548
+ if (shared)
549
+ await shared.close();
457
550
  lines.push(`\nTranslated: ${translated} | Skipped: ${skipped} | Failed: ${failed}`);
458
551
  } // end if entries.length > 0
459
552
  }
@@ -544,7 +637,7 @@ export async function runStats(opts) {
544
637
  }
545
638
  // CLI entry point
546
639
  const program = new Command();
547
- program.name('transduck').description('AI-native translation tool').version('0.6.7');
640
+ program.name('transduck').description('AI-native translation tool').version('0.6.9');
548
641
  program.command('init')
549
642
  .description('Initialize a new transduck project')
550
643
  .action(async () => {
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transduck",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
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
@@ -459,6 +459,19 @@ export async function runScan(opts: ScanOptions): Promise<string> {
459
459
  await store.initialize();
460
460
  const projectContextHash = hash(cfg.projectContext);
461
461
 
462
+ // Initialize shared Postgres store if configured
463
+ let shared: SharedStore | null = null;
464
+ if (cfg.sharedUrl) {
465
+ try {
466
+ shared = new SharedStore(cfg.sharedUrl);
467
+ await shared.initialize();
468
+ console.log(' Connected to shared Postgres store');
469
+ } catch (err) {
470
+ console.warn(` Warning: could not connect to shared store: ${(err as Error).message}`);
471
+ shared = null;
472
+ }
473
+ }
474
+
462
475
  let translated = 0;
463
476
  let skipped = 0;
464
477
  let failed = 0;
@@ -470,20 +483,56 @@ export async function runScan(opts: ScanOptions): Promise<string> {
470
483
  const sourceKey = entry.one + '\x00' + entry.other;
471
484
  const stringContextHash = hash(entry.context ?? '');
472
485
  const label = (entry.one ?? '').slice(0, 40);
486
+ const lookupParams = {
487
+ sourceText: sourceKey, sourceLang: cfg.sourceLang,
488
+ projectContextHash, stringContextHash,
489
+ };
473
490
 
474
491
  for (const lang of targetLangs) {
475
492
  done++;
476
- const cachedForms = await store.lookupPlural({
477
- sourceText: sourceKey, sourceLang: cfg.sourceLang, targetLang: lang,
478
- projectContextHash, stringContextHash,
479
- });
493
+ const langLookup = { ...lookupParams, targetLang: lang };
494
+ const cachedForms = await store.lookupPlural(langLookup);
480
495
 
481
496
  if (Object.keys(cachedForms).length > 0) {
497
+ // Ensure shared store has it too
498
+ if (shared) {
499
+ try {
500
+ const sharedForms = await shared.lookupPlural(langLookup);
501
+ if (Object.keys(sharedForms).length === 0) {
502
+ for (const [cat, text] of Object.entries(cachedForms)) {
503
+ await shared.insertPlural({
504
+ ...langLookup, stringContext: entry.context ?? '',
505
+ pluralCategory: cat, translatedText: text,
506
+ model: cfg.backendModel, status: 'translated',
507
+ });
508
+ }
509
+ }
510
+ } catch { /* ignore shared store errors */ }
511
+ }
482
512
  skipped++;
483
513
  console.log(` [${done}/${total}] ${lang} skipped (cached): ${label}`);
484
514
  continue;
485
515
  }
486
516
 
517
+ // Check shared store before calling backend
518
+ if (shared) {
519
+ try {
520
+ const sharedForms = await shared.lookupPlural(langLookup);
521
+ if (Object.keys(sharedForms).length > 0) {
522
+ for (const [cat, text] of Object.entries(sharedForms)) {
523
+ await store.insertPlural({
524
+ ...langLookup, stringContext: entry.context ?? '',
525
+ pluralCategory: cat, translatedText: text,
526
+ model: cfg.backendModel, status: 'translated',
527
+ });
528
+ }
529
+ skipped++;
530
+ console.log(` [${done}/${total}] ${lang} skipped (shared cache): ${label}`);
531
+ continue;
532
+ }
533
+ } catch { /* ignore */ }
534
+ }
535
+
487
536
  try {
488
537
  const forms = await backendTranslatePlural(
489
538
  entry.one!, entry.other!,
@@ -492,12 +541,13 @@ export async function runScan(opts: ScanOptions): Promise<string> {
492
541
  );
493
542
 
494
543
  for (const [cat, translatedText] of Object.entries(forms)) {
495
- await store.insertPlural({
496
- sourceText: sourceKey, sourceLang: cfg.sourceLang, targetLang: lang,
497
- projectContextHash, stringContextHash, stringContext: entry.context ?? '',
544
+ const insertParams = {
545
+ ...langLookup, stringContext: entry.context ?? '',
498
546
  pluralCategory: cat, translatedText: translatedText as string,
499
547
  model: cfg.backendModel, status: 'translated',
500
- });
548
+ };
549
+ await store.insertPlural(insertParams);
550
+ if (shared) try { await shared.insertPlural(insertParams); } catch { /* ignore */ }
501
551
  }
502
552
  translated++;
503
553
  console.log(` [${done}/${total}] ${lang} translated: ${label}`);
@@ -509,20 +559,50 @@ export async function runScan(opts: ScanOptions): Promise<string> {
509
559
  } else {
510
560
  const stringContextHash = hash(entry.context ?? '');
511
561
  const label = (entry.text ?? '').slice(0, 40);
562
+ const lookupParams = {
563
+ sourceText: entry.text!, sourceLang: cfg.sourceLang,
564
+ projectContextHash, stringContextHash,
565
+ };
512
566
 
513
567
  for (const lang of targetLangs) {
514
568
  done++;
515
- const cached = await store.lookup({
516
- sourceText: entry.text!, sourceLang: cfg.sourceLang, targetLang: lang,
517
- projectContextHash, stringContextHash,
518
- });
569
+ const langLookup = { ...lookupParams, targetLang: lang };
570
+ const cached = await store.lookup(langLookup);
519
571
 
520
572
  if (cached !== null) {
573
+ // Ensure shared store has it too
574
+ if (shared) {
575
+ try {
576
+ const sharedCached = await shared.lookup(langLookup);
577
+ if (sharedCached === null) {
578
+ await shared.insert({
579
+ ...langLookup, stringContext: entry.context ?? '',
580
+ translatedText: cached, model: cfg.backendModel, status: 'translated',
581
+ });
582
+ }
583
+ } catch { /* ignore */ }
584
+ }
521
585
  skipped++;
522
586
  console.log(` [${done}/${total}] ${lang} skipped (cached): ${label}`);
523
587
  continue;
524
588
  }
525
589
 
590
+ // Check shared store before calling backend
591
+ if (shared) {
592
+ try {
593
+ const sharedCached = await shared.lookup(langLookup);
594
+ if (sharedCached !== null) {
595
+ await store.insert({
596
+ ...langLookup, stringContext: entry.context ?? '',
597
+ translatedText: sharedCached, model: cfg.backendModel, status: 'translated',
598
+ });
599
+ skipped++;
600
+ console.log(` [${done}/${total}] ${lang} skipped (shared cache): ${label}`);
601
+ continue;
602
+ }
603
+ } catch { /* ignore */ }
604
+ }
605
+
526
606
  try {
527
607
  const result = await backendTranslate(
528
608
  entry.text!, cfg.sourceLang, lang,
@@ -530,11 +610,12 @@ export async function runScan(opts: ScanOptions): Promise<string> {
530
610
  );
531
611
 
532
612
  if (validateTranslation(entry.text!, result)) {
533
- await store.insert({
534
- sourceText: entry.text!, sourceLang: cfg.sourceLang, targetLang: lang,
535
- projectContextHash, stringContextHash, stringContext: entry.context ?? '',
613
+ const insertParams = {
614
+ ...langLookup, stringContext: entry.context ?? '',
536
615
  translatedText: result, model: cfg.backendModel, status: 'translated',
537
- });
616
+ };
617
+ await store.insert(insertParams);
618
+ if (shared) try { await shared.insert(insertParams); } catch { /* ignore */ }
538
619
  translated++;
539
620
  console.log(` [${done}/${total}] ${lang} translated: ${label}`);
540
621
  } else {
@@ -550,6 +631,7 @@ export async function runScan(opts: ScanOptions): Promise<string> {
550
631
  }
551
632
 
552
633
  store.close();
634
+ if (shared) await shared.close();
553
635
  lines.push(`\nTranslated: ${translated} | Skipped: ${skipped} | Failed: ${failed}`);
554
636
  } // end if entries.length > 0
555
637
  }
@@ -661,7 +743,7 @@ export async function runStats(opts: StatsOptions): Promise<string> {
661
743
  // CLI entry point
662
744
  const program = new Command();
663
745
 
664
- program.name('transduck').description('AI-native translation tool').version('0.6.7');
746
+ program.name('transduck').description('AI-native translation tool').version('0.6.9');
665
747
 
666
748
  program.command('init')
667
749
  .description('Initialize a new transduck project')
@@ -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
@@ -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,136 @@ 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
+
502
633
  it('useTransDuck() still works without destructuring (backward compat)', async () => {
503
634
  mockFetch.mockResolvedValue({
504
635
  ok: true,