transduck 0.0.4 → 0.1.1
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.d.ts +8 -0
- package/dist/cli.js +167 -2
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -0
- package/dist/handler.d.ts +23 -0
- package/dist/handler.js +153 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +11 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +1 -0
- package/dist/react/provider.d.ts +23 -0
- package/dist/react/provider.js +218 -0
- package/dist/scanner.d.ts +21 -0
- package/dist/scanner.js +180 -0
- package/package.json +25 -1
- package/src/cli.ts +195 -2
- package/src/config.ts +2 -0
- package/src/handler.ts +193 -0
- package/src/index.ts +14 -0
- package/src/react/index.ts +1 -0
- package/src/react/provider.tsx +287 -0
- package/src/scanner.ts +215 -0
- package/tests/cli.test.ts +63 -2
- package/tests/handler.test.ts +136 -0
- package/tests/react-provider.test.tsx +162 -0
- package/tests/scanner.test.ts +191 -0
- package/tsconfig.json +2 -1
- package/vitest.config.ts +9 -0
package/src/config.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface TransduckConfig {
|
|
|
14
14
|
backendModel: string;
|
|
15
15
|
backendTimeout: number;
|
|
16
16
|
backendMaxRetries: number;
|
|
17
|
+
readOnly: boolean;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
function findConfig(): string {
|
|
@@ -58,5 +59,6 @@ export function loadConfig(path?: string): TransduckConfig {
|
|
|
58
59
|
backendModel: raw.backend.model,
|
|
59
60
|
backendTimeout: raw.backend.timeout_seconds,
|
|
60
61
|
backendMaxRetries: raw.backend.max_retries,
|
|
62
|
+
readOnly: raw.runtime?.read_only ?? false,
|
|
61
63
|
};
|
|
62
64
|
}
|
package/src/handler.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { loadConfig } from './config.js';
|
|
3
|
+
import { TranslationStore } from './storage.js';
|
|
4
|
+
import { translate as backendTranslate, translatePlural as backendTranslatePlural } from './backend.js';
|
|
5
|
+
import { validateTranslation } from './validation.js';
|
|
6
|
+
|
|
7
|
+
function hash(text: string): string {
|
|
8
|
+
return createHash('sha256').update(text).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface TranslationRequestString {
|
|
12
|
+
text: string;
|
|
13
|
+
context?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface TranslationRequestPlural {
|
|
17
|
+
one: string;
|
|
18
|
+
other: string;
|
|
19
|
+
context?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TranslationRequest {
|
|
23
|
+
language: string;
|
|
24
|
+
strings: TranslationRequestString[];
|
|
25
|
+
plurals: TranslationRequestPlural[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TranslationResponse {
|
|
29
|
+
translations: Record<string, string>;
|
|
30
|
+
plurals: Record<string, Record<string, string>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let _store: TranslationStore | null = null;
|
|
34
|
+
|
|
35
|
+
async function getStore(configPath?: string) {
|
|
36
|
+
if (!_store) {
|
|
37
|
+
const cfg = loadConfig(configPath);
|
|
38
|
+
_store = new TranslationStore(cfg.storagePath);
|
|
39
|
+
await _store.initialize();
|
|
40
|
+
}
|
|
41
|
+
return _store;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @internal Reset the singleton store — for testing only. */
|
|
45
|
+
export function _resetHandlerStore(): void {
|
|
46
|
+
if (_store) {
|
|
47
|
+
_store.close();
|
|
48
|
+
}
|
|
49
|
+
_store = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function handleTranslationRequest(
|
|
53
|
+
body: TranslationRequest,
|
|
54
|
+
configPath?: string,
|
|
55
|
+
): Promise<TranslationResponse> {
|
|
56
|
+
const cfg = loadConfig(configPath);
|
|
57
|
+
const store = await getStore(configPath);
|
|
58
|
+
const targetLang = body.language.toUpperCase();
|
|
59
|
+
|
|
60
|
+
const projectContextHash = hash(cfg.projectContext);
|
|
61
|
+
const apiKey = process.env[cfg.apiKeyEnv];
|
|
62
|
+
|
|
63
|
+
const translations: Record<string, string> = {};
|
|
64
|
+
const plurals: Record<string, Record<string, string>> = {};
|
|
65
|
+
|
|
66
|
+
// Translate regular strings
|
|
67
|
+
for (const item of body.strings) {
|
|
68
|
+
const stringContextHash = hash(item.context ?? '');
|
|
69
|
+
const key = `${item.text}||${item.context ?? ''}`;
|
|
70
|
+
|
|
71
|
+
// Cache lookup
|
|
72
|
+
const cached = await store.lookup({
|
|
73
|
+
sourceText: item.text,
|
|
74
|
+
sourceLang: cfg.sourceLang,
|
|
75
|
+
targetLang,
|
|
76
|
+
projectContextHash,
|
|
77
|
+
stringContextHash,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (cached !== null) {
|
|
81
|
+
translations[key] = cached;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Backend call
|
|
86
|
+
try {
|
|
87
|
+
const translated = await backendTranslate({
|
|
88
|
+
sourceText: item.text,
|
|
89
|
+
sourceLang: cfg.sourceLang,
|
|
90
|
+
targetLang,
|
|
91
|
+
projectContext: cfg.projectContext,
|
|
92
|
+
stringContext: item.context ?? null,
|
|
93
|
+
apiKey: apiKey!,
|
|
94
|
+
model: cfg.backendModel,
|
|
95
|
+
timeout: cfg.backendTimeout,
|
|
96
|
+
maxRetries: cfg.backendMaxRetries,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (validateTranslation(item.text, translated)) {
|
|
100
|
+
await store.insert({
|
|
101
|
+
sourceText: item.text,
|
|
102
|
+
sourceLang: cfg.sourceLang,
|
|
103
|
+
targetLang,
|
|
104
|
+
projectContextHash,
|
|
105
|
+
stringContextHash,
|
|
106
|
+
translatedText: translated,
|
|
107
|
+
model: cfg.backendModel,
|
|
108
|
+
status: 'translated',
|
|
109
|
+
});
|
|
110
|
+
translations[key] = translated;
|
|
111
|
+
} else {
|
|
112
|
+
translations[key] = item.text;
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
translations[key] = item.text;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Translate plurals
|
|
120
|
+
for (const item of body.plurals) {
|
|
121
|
+
const stringContextHash = hash(item.context ?? '');
|
|
122
|
+
const sourceKey = item.one + '\x00' + item.other;
|
|
123
|
+
const responseKey = `${sourceKey}||${item.context ?? ''}`;
|
|
124
|
+
|
|
125
|
+
// Cache lookup
|
|
126
|
+
const cachedForms = await store.lookupPlural({
|
|
127
|
+
sourceText: sourceKey,
|
|
128
|
+
sourceLang: cfg.sourceLang,
|
|
129
|
+
targetLang,
|
|
130
|
+
projectContextHash,
|
|
131
|
+
stringContextHash,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (Object.keys(cachedForms).length > 0) {
|
|
135
|
+
plurals[responseKey] = cachedForms;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Backend call
|
|
140
|
+
try {
|
|
141
|
+
const forms = await backendTranslatePlural({
|
|
142
|
+
one: item.one,
|
|
143
|
+
other: item.other,
|
|
144
|
+
sourceLang: cfg.sourceLang,
|
|
145
|
+
targetLang,
|
|
146
|
+
projectContext: cfg.projectContext,
|
|
147
|
+
stringContext: item.context ?? null,
|
|
148
|
+
apiKey: apiKey!,
|
|
149
|
+
model: cfg.backendModel,
|
|
150
|
+
timeout: cfg.backendTimeout,
|
|
151
|
+
maxRetries: cfg.backendMaxRetries,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
for (const [cat, translatedText] of Object.entries(forms)) {
|
|
155
|
+
await store.insertPlural({
|
|
156
|
+
sourceText: sourceKey,
|
|
157
|
+
sourceLang: cfg.sourceLang,
|
|
158
|
+
targetLang,
|
|
159
|
+
projectContextHash,
|
|
160
|
+
stringContextHash,
|
|
161
|
+
pluralCategory: cat,
|
|
162
|
+
translatedText: translatedText as string,
|
|
163
|
+
model: cfg.backendModel,
|
|
164
|
+
status: 'translated',
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
plurals[responseKey] = forms;
|
|
168
|
+
} catch {
|
|
169
|
+
plurals[responseKey] = { one: item.one, other: item.other };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { translations, plurals };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function createTransDuckHandler(configPath?: string) {
|
|
177
|
+
return async function POST(request: Request): Promise<Response> {
|
|
178
|
+
try {
|
|
179
|
+
const body = await request.json() as TranslationRequest;
|
|
180
|
+
const result = await handleTranslationRequest(body, configPath);
|
|
181
|
+
return new Response(JSON.stringify(result), {
|
|
182
|
+
status: 200,
|
|
183
|
+
headers: { 'Content-Type': 'application/json' },
|
|
184
|
+
});
|
|
185
|
+
} catch (err) {
|
|
186
|
+
console.error('[transduck] Handler error:', err);
|
|
187
|
+
return new Response(JSON.stringify({ error: 'Translation failed' }), {
|
|
188
|
+
status: 500,
|
|
189
|
+
headers: { 'Content-Type': 'application/json' },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -71,6 +71,11 @@ export async function ait(
|
|
|
71
71
|
});
|
|
72
72
|
if (cached !== null) return interpolateVars(cached, vars);
|
|
73
73
|
|
|
74
|
+
// Read-only mode: skip backend, return source text
|
|
75
|
+
if (cfg.readOnly) {
|
|
76
|
+
return interpolateVars(sourceText, vars);
|
|
77
|
+
}
|
|
78
|
+
|
|
74
79
|
// In-process dedup
|
|
75
80
|
const lockKey = `${sourceText}|${cfg.sourceLang}|${state.targetLang}|${projectContextHash}|${stringContextHash}`;
|
|
76
81
|
|
|
@@ -125,6 +130,8 @@ export async function ait(
|
|
|
125
130
|
return interpolateVars(result, vars);
|
|
126
131
|
}
|
|
127
132
|
|
|
133
|
+
export { createTransDuckHandler } from './handler.js';
|
|
134
|
+
|
|
128
135
|
export async function aitPlural(
|
|
129
136
|
one: string,
|
|
130
137
|
other: string,
|
|
@@ -177,6 +184,13 @@ export async function aitPlural(
|
|
|
177
184
|
return interpolateVars(cached[category], vars);
|
|
178
185
|
}
|
|
179
186
|
|
|
187
|
+
// Read-only mode: skip backend, fall back to source forms
|
|
188
|
+
if (cfg.readOnly) {
|
|
189
|
+
const fallbackCategory = getPluralCategory(cfg.sourceLang, count);
|
|
190
|
+
const fallback = fallbackCategory === 'one' ? one : other;
|
|
191
|
+
return interpolateVars(fallback, vars);
|
|
192
|
+
}
|
|
193
|
+
|
|
180
194
|
// Cache miss — call backend
|
|
181
195
|
const apiKey = process.env[cfg.apiKeyEnv];
|
|
182
196
|
try {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState } from './provider.js';
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
// --- Module-level state (accessed by stable t()/ait() functions) ---
|
|
6
|
+
|
|
7
|
+
interface ReactState {
|
|
8
|
+
language: string;
|
|
9
|
+
sourceLang: string;
|
|
10
|
+
endpoint: string;
|
|
11
|
+
cache: Map<string, string>;
|
|
12
|
+
pluralCache: Map<string, Record<string, string>>;
|
|
13
|
+
pendingStrings: Set<string>; // "text||context"
|
|
14
|
+
pendingPlurals: Set<string>; // "one\x00other||context"
|
|
15
|
+
triggerFetch: (() => void) | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let _state: ReactState = {
|
|
19
|
+
language: '',
|
|
20
|
+
sourceLang: 'EN',
|
|
21
|
+
endpoint: '/api/translations',
|
|
22
|
+
cache: new Map(),
|
|
23
|
+
pluralCache: new Map(),
|
|
24
|
+
pendingStrings: new Set(),
|
|
25
|
+
pendingPlurals: new Set(),
|
|
26
|
+
triggerFetch: null,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// --- Context for triggering child re-renders ---
|
|
30
|
+
// The context value (a version counter) changes when translations are loaded.
|
|
31
|
+
// Components that call useTransDuck() subscribe to this and re-render automatically.
|
|
32
|
+
const TransDuckContext = createContext<number>(0);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Hook that subscribes to translation updates. Call this in any component
|
|
36
|
+
* that uses t()/ait() to ensure it re-renders when translations are loaded.
|
|
37
|
+
*/
|
|
38
|
+
export function useTransDuck(): void {
|
|
39
|
+
useContext(TransDuckContext);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function _resetReactState(): void {
|
|
43
|
+
_state = {
|
|
44
|
+
language: '',
|
|
45
|
+
sourceLang: 'EN',
|
|
46
|
+
endpoint: '/api/translations',
|
|
47
|
+
cache: new Map(),
|
|
48
|
+
pluralCache: new Map(),
|
|
49
|
+
pendingStrings: new Set(),
|
|
50
|
+
pendingPlurals: new Set(),
|
|
51
|
+
triggerFetch: null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function interpolateVars(text: string, vars?: Record<string, string | number> | null): string {
|
|
56
|
+
if (!vars) return text;
|
|
57
|
+
let result = text;
|
|
58
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
59
|
+
result = result.replaceAll(`{${key}}`, String(value));
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- Stable exported functions ---
|
|
65
|
+
|
|
66
|
+
export function t(
|
|
67
|
+
sourceText: string,
|
|
68
|
+
context?: string,
|
|
69
|
+
vars?: Record<string, string | number>,
|
|
70
|
+
): string {
|
|
71
|
+
// Same language — return source
|
|
72
|
+
if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
|
|
73
|
+
return interpolateVars(sourceText, vars);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const key = `${sourceText}||${context ?? ''}`;
|
|
77
|
+
|
|
78
|
+
// Cache hit
|
|
79
|
+
const cached = _state.cache.get(key);
|
|
80
|
+
if (cached !== undefined) {
|
|
81
|
+
return interpolateVars(cached, vars);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Queue for fetch (useEffect in provider will flush after render)
|
|
85
|
+
_state.pendingStrings.add(key);
|
|
86
|
+
|
|
87
|
+
// Return source text as fallback
|
|
88
|
+
return interpolateVars(sourceText, vars);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function tPlural(
|
|
92
|
+
one: string,
|
|
93
|
+
other: string,
|
|
94
|
+
count: number,
|
|
95
|
+
opts?: { context?: string; vars?: Record<string, string | number> },
|
|
96
|
+
): string {
|
|
97
|
+
const context = opts?.context;
|
|
98
|
+
let vars: Record<string, string | number>;
|
|
99
|
+
if (!opts?.vars) {
|
|
100
|
+
vars = { count };
|
|
101
|
+
} else if (!('count' in opts.vars)) {
|
|
102
|
+
vars = { ...opts.vars, count };
|
|
103
|
+
} else {
|
|
104
|
+
vars = { ...opts.vars };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Same language
|
|
108
|
+
if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
|
|
109
|
+
const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
|
|
110
|
+
const form = rules.select(count) === 'one' ? one : other;
|
|
111
|
+
return interpolateVars(form, vars);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const sourceKey = `${one}\x00${other}`;
|
|
115
|
+
const cacheKey = `${sourceKey}||${context ?? ''}`;
|
|
116
|
+
|
|
117
|
+
// Cache hit
|
|
118
|
+
const cachedForms = _state.pluralCache.get(cacheKey);
|
|
119
|
+
if (cachedForms) {
|
|
120
|
+
const rules = new Intl.PluralRules(_state.language.toLowerCase());
|
|
121
|
+
const category = rules.select(count);
|
|
122
|
+
const form = cachedForms[category] ?? cachedForms['other'] ?? other;
|
|
123
|
+
return interpolateVars(form, vars);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Queue for fetch (useEffect in provider will flush after render)
|
|
127
|
+
_state.pendingPlurals.add(cacheKey);
|
|
128
|
+
|
|
129
|
+
// Fallback to source form
|
|
130
|
+
const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
|
|
131
|
+
const form = rules.select(count) === 'one' ? one : other;
|
|
132
|
+
return interpolateVars(form, vars);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Aliases
|
|
136
|
+
export const ait = t;
|
|
137
|
+
export const aitPlural = tPlural;
|
|
138
|
+
|
|
139
|
+
// --- LocalStorage helpers ---
|
|
140
|
+
|
|
141
|
+
function loadFromLocalStorage(projectName: string, language: string): void {
|
|
142
|
+
try {
|
|
143
|
+
const storageKey = `transduck:${projectName}:${language}`;
|
|
144
|
+
const stored = localStorage.getItem(storageKey);
|
|
145
|
+
if (stored) {
|
|
146
|
+
const data = JSON.parse(stored);
|
|
147
|
+
if (data.translations) {
|
|
148
|
+
for (const [k, v] of Object.entries(data.translations)) {
|
|
149
|
+
_state.cache.set(k, v as string);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (data.plurals) {
|
|
153
|
+
for (const [k, v] of Object.entries(data.plurals)) {
|
|
154
|
+
_state.pluralCache.set(k, v as Record<string, string>);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
// localStorage not available or corrupt — ignore
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function saveToLocalStorage(projectName: string, language: string): void {
|
|
164
|
+
try {
|
|
165
|
+
const storageKey = `transduck:${projectName}:${language}`;
|
|
166
|
+
const data = {
|
|
167
|
+
translations: Object.fromEntries(_state.cache),
|
|
168
|
+
plurals: Object.fromEntries(_state.pluralCache),
|
|
169
|
+
};
|
|
170
|
+
localStorage.setItem(storageKey, JSON.stringify(data));
|
|
171
|
+
} catch {
|
|
172
|
+
// localStorage not available — ignore
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- Provider ---
|
|
177
|
+
|
|
178
|
+
interface TransDuckProviderProps {
|
|
179
|
+
language: string;
|
|
180
|
+
sourceLang?: string;
|
|
181
|
+
endpoint?: string;
|
|
182
|
+
projectName?: string;
|
|
183
|
+
children: ReactNode;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function TransDuckProvider({
|
|
187
|
+
language,
|
|
188
|
+
sourceLang = 'EN',
|
|
189
|
+
endpoint = '/api/translations',
|
|
190
|
+
projectName = 'default',
|
|
191
|
+
children,
|
|
192
|
+
}: TransDuckProviderProps) {
|
|
193
|
+
const [version, setVersion] = useState(0);
|
|
194
|
+
|
|
195
|
+
// Update module-level state synchronously so t() works during first render
|
|
196
|
+
_state.endpoint = endpoint;
|
|
197
|
+
_state.sourceLang = sourceLang;
|
|
198
|
+
|
|
199
|
+
const upperLang = language.toUpperCase();
|
|
200
|
+
if (_state.language !== upperLang) {
|
|
201
|
+
_state.language = upperLang;
|
|
202
|
+
_state.cache.clear();
|
|
203
|
+
_state.pluralCache.clear();
|
|
204
|
+
loadFromLocalStorage(projectName, upperLang);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Set up trigger
|
|
208
|
+
const doFetch = useCallback(async () => {
|
|
209
|
+
const stringsToFetch = new Set(_state.pendingStrings);
|
|
210
|
+
const pluralsToFetch = new Set(_state.pendingPlurals);
|
|
211
|
+
_state.pendingStrings.clear();
|
|
212
|
+
_state.pendingPlurals.clear();
|
|
213
|
+
|
|
214
|
+
// Filter out already-cached
|
|
215
|
+
for (const key of stringsToFetch) {
|
|
216
|
+
if (_state.cache.has(key)) stringsToFetch.delete(key);
|
|
217
|
+
}
|
|
218
|
+
for (const key of pluralsToFetch) {
|
|
219
|
+
if (_state.pluralCache.has(key)) pluralsToFetch.delete(key);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (stringsToFetch.size === 0 && pluralsToFetch.size === 0) return;
|
|
223
|
+
|
|
224
|
+
// Build request body
|
|
225
|
+
const strings = Array.from(stringsToFetch).map(key => {
|
|
226
|
+
const [text, context] = key.split('||');
|
|
227
|
+
return { text, context: context || undefined };
|
|
228
|
+
});
|
|
229
|
+
const plurals = Array.from(pluralsToFetch).map(key => {
|
|
230
|
+
const [sourceKey, context] = key.split('||');
|
|
231
|
+
const [one, other] = sourceKey.split('\x00');
|
|
232
|
+
return { one, other, context: context || undefined };
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const response = await fetch(endpoint, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: { 'Content-Type': 'application/json' },
|
|
239
|
+
body: JSON.stringify({
|
|
240
|
+
language: _state.language,
|
|
241
|
+
strings,
|
|
242
|
+
plurals,
|
|
243
|
+
}),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
console.warn(`[transduck] Translation fetch failed: ${response.status}`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const data = await response.json();
|
|
252
|
+
|
|
253
|
+
// Store translations
|
|
254
|
+
if (data.translations) {
|
|
255
|
+
for (const [key, value] of Object.entries(data.translations)) {
|
|
256
|
+
_state.cache.set(key, value as string);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (data.plurals) {
|
|
260
|
+
for (const [key, value] of Object.entries(data.plurals)) {
|
|
261
|
+
_state.pluralCache.set(key, value as Record<string, string>);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
saveToLocalStorage(projectName, _state.language);
|
|
266
|
+
setVersion(v => v + 1);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.warn('[transduck] Translation fetch error:', err);
|
|
269
|
+
}
|
|
270
|
+
}, [endpoint, projectName]);
|
|
271
|
+
|
|
272
|
+
// Register trigger
|
|
273
|
+
_state.triggerFetch = doFetch;
|
|
274
|
+
|
|
275
|
+
// Flush pending after render
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
if (_state.pendingStrings.size > 0 || _state.pendingPlurals.size > 0) {
|
|
278
|
+
doFetch();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<TransDuckContext.Provider value={version}>
|
|
284
|
+
{children}
|
|
285
|
+
</TransDuckContext.Provider>
|
|
286
|
+
);
|
|
287
|
+
}
|