@sonenta/react-i18next 2.0.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/LICENSE +21 -0
- package/README.md +540 -0
- package/dist/index.cjs +1326 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +540 -0
- package/dist/index.d.ts +540 -0
- package/dist/index.js +1299 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1299 @@
|
|
|
1
|
+
// src/provider.tsx
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
Fragment,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useSyncExternalStore
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
// src/i18n.ts
|
|
12
|
+
import {
|
|
13
|
+
createInstance as createInstance2
|
|
14
|
+
} from "i18next";
|
|
15
|
+
import { initReactI18next as initReactI18next2 } from "react-i18next";
|
|
16
|
+
|
|
17
|
+
// src/catalog.ts
|
|
18
|
+
var RTL_LANGS = /* @__PURE__ */ new Set([
|
|
19
|
+
"ar",
|
|
20
|
+
"arc",
|
|
21
|
+
"ckb",
|
|
22
|
+
"dv",
|
|
23
|
+
"fa",
|
|
24
|
+
"ha",
|
|
25
|
+
"he",
|
|
26
|
+
"khw",
|
|
27
|
+
"ks",
|
|
28
|
+
"ku",
|
|
29
|
+
"nqo",
|
|
30
|
+
"ps",
|
|
31
|
+
"sd",
|
|
32
|
+
"ug",
|
|
33
|
+
"ur",
|
|
34
|
+
"yi"
|
|
35
|
+
]);
|
|
36
|
+
function localeChain(locale) {
|
|
37
|
+
const parts = locale.split("-").filter(Boolean);
|
|
38
|
+
if (parts.length <= 1) return [locale];
|
|
39
|
+
const chain = [];
|
|
40
|
+
for (let i = parts.length; i >= 1; i--) chain.push(parts.slice(0, i).join("-"));
|
|
41
|
+
return chain;
|
|
42
|
+
}
|
|
43
|
+
async function loadCatalog(apiBase, fetchImpl = fetch) {
|
|
44
|
+
try {
|
|
45
|
+
const url = `${apiBase.replace(/\/+$/, "")}/v1/languages`;
|
|
46
|
+
const r = await fetchImpl(url, { method: "GET", credentials: "omit" });
|
|
47
|
+
if (!r.ok) return null;
|
|
48
|
+
const data = await r.json();
|
|
49
|
+
return Array.isArray(data) ? data : null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
var LanguageCatalog = class {
|
|
55
|
+
_catalog = /* @__PURE__ */ new Map();
|
|
56
|
+
/** Merge catalog entries into the map, keyed by lower-cased BCP-47 code. */
|
|
57
|
+
merge(items) {
|
|
58
|
+
if (!items) return;
|
|
59
|
+
for (const item of items) {
|
|
60
|
+
if (item && typeof item.code === "string") {
|
|
61
|
+
this._catalog.set(item.code.toLowerCase(), item);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Catalog entry for a locale, walking variant→base (`fr-CA` → `fr`);
|
|
66
|
+
* `undefined` when no entry in the chain matches. */
|
|
67
|
+
metaFor(locale) {
|
|
68
|
+
for (const l of localeChain(locale)) {
|
|
69
|
+
const hit = this._catalog.get(l.toLowerCase());
|
|
70
|
+
if (hit) return hit;
|
|
71
|
+
}
|
|
72
|
+
return void 0;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Text direction for a locale — i18next parity. Catalog `rtl` is
|
|
76
|
+
* authoritative (with variant→base inheritance); falls back to the built-in
|
|
77
|
+
* {@link RTL_LANGS} primary-subtag check before/without the catalog, else
|
|
78
|
+
* `'ltr'`.
|
|
79
|
+
*/
|
|
80
|
+
dir(locale) {
|
|
81
|
+
const meta = this.metaFor(locale);
|
|
82
|
+
if (meta && typeof meta.rtl === "boolean") return meta.rtl ? "rtl" : "ltr";
|
|
83
|
+
const primary = locale.split("-")[0]?.toLowerCase();
|
|
84
|
+
return primary && RTL_LANGS.has(primary) ? "rtl" : "ltr";
|
|
85
|
+
}
|
|
86
|
+
/** Endonym (native name) for a locale from the catalog; `undefined` when the
|
|
87
|
+
* catalog has no matching entry (no built-in fallback). */
|
|
88
|
+
nativeName(locale) {
|
|
89
|
+
return this.metaFor(locale)?.native_name;
|
|
90
|
+
}
|
|
91
|
+
/** Full catalog entry for a locale; `undefined` when unknown. */
|
|
92
|
+
languageMeta(locale) {
|
|
93
|
+
return this.metaFor(locale);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// src/engine.ts
|
|
98
|
+
import {
|
|
99
|
+
createInstance
|
|
100
|
+
} from "i18next";
|
|
101
|
+
import { initReactI18next } from "react-i18next";
|
|
102
|
+
|
|
103
|
+
// src/missing.ts
|
|
104
|
+
var MissingKeyManager = class {
|
|
105
|
+
/** Newest-first ring buffer for in-app inspectors; capped at `bufferSize`. */
|
|
106
|
+
missingEvents = [];
|
|
107
|
+
transport;
|
|
108
|
+
missingHandler;
|
|
109
|
+
flushIntervalMs;
|
|
110
|
+
flushBatchSize;
|
|
111
|
+
bufferSize;
|
|
112
|
+
pending = [];
|
|
113
|
+
seen = /* @__PURE__ */ new Set();
|
|
114
|
+
// dedup `${language_code}/${namespace}/${key}`
|
|
115
|
+
timer = null;
|
|
116
|
+
constructor(opts) {
|
|
117
|
+
this.transport = opts.transport;
|
|
118
|
+
this.missingHandler = opts.missingHandler;
|
|
119
|
+
this.flushIntervalMs = opts.flushIntervalMs;
|
|
120
|
+
this.flushBatchSize = opts.flushBatchSize;
|
|
121
|
+
this.bufferSize = opts.bufferSize;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Record a (already-vetted) missing-key event. Dedups within the buffer,
|
|
125
|
+
* pushes onto the capped ring buffer + the pending batch, and force-flushes
|
|
126
|
+
* when the batch reaches `flushBatchSize`. No-op when `missingHandler` is
|
|
127
|
+
* `'off'` (matches legacy `_reportMissing`).
|
|
128
|
+
*
|
|
129
|
+
* Returns `true` only when the event was NEWLY recorded (not a dedup / not
|
|
130
|
+
* `off`). Callers MUST gate any re-render (`_notify`) on this — notifying on
|
|
131
|
+
* a deduped report is what caused the rc render-loop: a missing key re-fires
|
|
132
|
+
* the handler on every render, so an unconditional notify loops forever.
|
|
133
|
+
*/
|
|
134
|
+
record(event) {
|
|
135
|
+
if (this.missingHandler === "off") return false;
|
|
136
|
+
const dedupKey = `${event.language_code}/${event.namespace}/${event.key}`;
|
|
137
|
+
if (this.seen.has(dedupKey)) return false;
|
|
138
|
+
this.seen.add(dedupKey);
|
|
139
|
+
this.missingEvents = [event, ...this.missingEvents].slice(0, this.bufferSize);
|
|
140
|
+
this.pending.push(event);
|
|
141
|
+
if (this.pending.length >= this.flushBatchSize) {
|
|
142
|
+
void this.flush();
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Drain the pending batch to the transport. No-op (and keeps pending intact)
|
|
148
|
+
* when `'off'`; otherwise takes + clears pending and `await`s the transport,
|
|
149
|
+
* swallowing any error (best-effort delivery).
|
|
150
|
+
*/
|
|
151
|
+
async flush() {
|
|
152
|
+
if (this.missingHandler === "off") return;
|
|
153
|
+
if (!this.pending.length) return;
|
|
154
|
+
const batch = this.pending.slice(0);
|
|
155
|
+
this.pending = [];
|
|
156
|
+
try {
|
|
157
|
+
await this.transport(batch);
|
|
158
|
+
} catch {
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** Arm the periodic flush loop. Guarded for non-browser runtimes / `'off'`. */
|
|
162
|
+
start() {
|
|
163
|
+
if (this.missingHandler === "off") return;
|
|
164
|
+
if (typeof setInterval !== "function") return;
|
|
165
|
+
if (this.timer) return;
|
|
166
|
+
this.timer = setInterval(() => {
|
|
167
|
+
void this.flush();
|
|
168
|
+
}, this.flushIntervalMs);
|
|
169
|
+
}
|
|
170
|
+
/** Clear the periodic flush loop. */
|
|
171
|
+
stop() {
|
|
172
|
+
if (this.timer) {
|
|
173
|
+
clearInterval(this.timer);
|
|
174
|
+
this.timer = null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// src/plurals.ts
|
|
180
|
+
var CLDR = ["zero", "one", "two", "few", "many", "other"];
|
|
181
|
+
function localeCategories(locale) {
|
|
182
|
+
try {
|
|
183
|
+
return new Intl.PluralRules(locale).resolvedOptions().pluralCategories;
|
|
184
|
+
} catch {
|
|
185
|
+
return ["other"];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function flattenPlurals(tree, locale) {
|
|
189
|
+
const cats = localeCategories(locale);
|
|
190
|
+
const out = {};
|
|
191
|
+
for (const [k, v] of Object.entries(tree)) {
|
|
192
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
193
|
+
const keys = Object.keys(v);
|
|
194
|
+
const isPlural = keys.length > 0 && keys.some((c) => CLDR.includes(c)) && keys.every((c) => typeof v[c] === "string");
|
|
195
|
+
if (isPlural) {
|
|
196
|
+
const vv = v;
|
|
197
|
+
const fallback = vv.other ?? vv[keys[0]];
|
|
198
|
+
for (const cat of /* @__PURE__ */ new Set([...keys, ...cats])) {
|
|
199
|
+
out[`${k}_${cat}`] = vv[cat] ?? fallback;
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
out[k] = flattenPlurals(v, locale);
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
out[k] = v;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/transport.ts
|
|
212
|
+
var SDK_LIB = "@sonenta/react-i18next";
|
|
213
|
+
var SDK_VER = true ? "2.0.0" : "0.0.0-dev";
|
|
214
|
+
function defaultTransport(opts) {
|
|
215
|
+
return async (batch) => {
|
|
216
|
+
if (!batch.length) return;
|
|
217
|
+
const body = {
|
|
218
|
+
project_uuid: opts.projectUuid,
|
|
219
|
+
events: batch.map((e) => ({
|
|
220
|
+
key: e.key,
|
|
221
|
+
namespace: e.namespace,
|
|
222
|
+
language_code: e.language_code,
|
|
223
|
+
// Option A (#746): only send source_value when there's a real value;
|
|
224
|
+
// omit it otherwise (never the key name). Absent = "no default".
|
|
225
|
+
...e.source_value !== void 0 ? { source_value: e.source_value } : {},
|
|
226
|
+
sdk_meta: {
|
|
227
|
+
lib: SDK_LIB,
|
|
228
|
+
ver: SDK_VER,
|
|
229
|
+
...typeof window !== "undefined" ? { url: window.location?.href } : {},
|
|
230
|
+
...e.sdk_meta ?? {}
|
|
231
|
+
}
|
|
232
|
+
}))
|
|
233
|
+
};
|
|
234
|
+
try {
|
|
235
|
+
await fetch(`${opts.apiBase.replace(/\/+$/, "")}/v1/missing`, {
|
|
236
|
+
method: "POST",
|
|
237
|
+
headers: {
|
|
238
|
+
"Content-Type": "application/json",
|
|
239
|
+
Authorization: `ApiKey ${opts.token}`
|
|
240
|
+
},
|
|
241
|
+
body: JSON.stringify(body),
|
|
242
|
+
// SDKs are best-effort; never block the render path
|
|
243
|
+
keepalive: true
|
|
244
|
+
});
|
|
245
|
+
} catch {
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
var logTransport = (batch) => {
|
|
250
|
+
for (const e of batch) {
|
|
251
|
+
console.warn("[sonenta] missing key", e);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// src/engine.ts
|
|
256
|
+
async function detectKeySeparator(apiBase, projectUuid, version, token, f) {
|
|
257
|
+
try {
|
|
258
|
+
const url = `${apiBase.replace(/\/+$/, "")}/v1/projects/${projectUuid}/versions/${encodeURIComponent(version)}`;
|
|
259
|
+
const r = await f(url, {
|
|
260
|
+
method: "GET",
|
|
261
|
+
headers: { Authorization: `ApiKey ${token}` },
|
|
262
|
+
credentials: "omit"
|
|
263
|
+
});
|
|
264
|
+
if (!r.ok) return ".";
|
|
265
|
+
const meta = await r.json();
|
|
266
|
+
return meta.key_style === "flat" ? false : meta.key_separator || ".";
|
|
267
|
+
} catch {
|
|
268
|
+
return ".";
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/key-registry.ts
|
|
273
|
+
var GLOBAL = "__verbumia_key_registry__";
|
|
274
|
+
var SEP = "\0";
|
|
275
|
+
function split(fullKey, defaultNamespace) {
|
|
276
|
+
const idx = fullKey.indexOf(":");
|
|
277
|
+
if (idx > 0) {
|
|
278
|
+
return { namespace: fullKey.slice(0, idx), key: fullKey.slice(idx + 1) };
|
|
279
|
+
}
|
|
280
|
+
return { namespace: defaultNamespace, key: fullKey };
|
|
281
|
+
}
|
|
282
|
+
var KeyRegistry = class {
|
|
283
|
+
// One Set per mounted hook/Trans instance (keyed by an opaque token).
|
|
284
|
+
// The on-screen set is the UNION of all live instances' latest render.
|
|
285
|
+
_instances = /* @__PURE__ */ new Map();
|
|
286
|
+
// Provider mounts that have published us onto globalThis. Ref-counted so
|
|
287
|
+
// a multi-provider tree (or fast unmount/remount in tests) never leaves
|
|
288
|
+
// a stale global or unpublishes while another provider is still live.
|
|
289
|
+
_providers = 0;
|
|
290
|
+
/** Replace an instance's contributed key set (per-render hook producer). */
|
|
291
|
+
_set(token, keys) {
|
|
292
|
+
this._instances.set(token, keys);
|
|
293
|
+
}
|
|
294
|
+
/** Append ONE id to a token's set, lazy-creating it. Used by the
|
|
295
|
+
* instance-level i18n.t wrap, which accumulates over the i18n
|
|
296
|
+
* instance's lifetime (no per-render reset semantics). Safe to call
|
|
297
|
+
* before `attach()` — the global publishes whenever a provider mounts. */
|
|
298
|
+
_track(token, id) {
|
|
299
|
+
let set = this._instances.get(token);
|
|
300
|
+
if (!set) {
|
|
301
|
+
set = /* @__PURE__ */ new Set();
|
|
302
|
+
this._instances.set(token, set);
|
|
303
|
+
}
|
|
304
|
+
set.add(id);
|
|
305
|
+
}
|
|
306
|
+
/** Drop an instance entirely (called on hook unmount or i18n stop). */
|
|
307
|
+
_delete(token) {
|
|
308
|
+
this._instances.delete(token);
|
|
309
|
+
}
|
|
310
|
+
/** Keys rendered by currently-mounted consumers. Stable insertion order. */
|
|
311
|
+
snapshot() {
|
|
312
|
+
const seen = /* @__PURE__ */ new Set();
|
|
313
|
+
const out = [];
|
|
314
|
+
for (const set of this._instances.values()) {
|
|
315
|
+
for (const id of set) {
|
|
316
|
+
if (seen.has(id)) continue;
|
|
317
|
+
seen.add(id);
|
|
318
|
+
const c = id.indexOf(SEP);
|
|
319
|
+
out.push({ namespace: id.slice(0, c), key: id.slice(c + 1) });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
/** True when ANY producer has contributed ≥1 key. Cheap O(producers)
|
|
325
|
+
* check exposed for DEV-time integration asserts ("did my
|
|
326
|
+
* useTranslation imports end up wired to @sonenta/react-i18next?"). */
|
|
327
|
+
isPopulated() {
|
|
328
|
+
for (const set of this._instances.values()) {
|
|
329
|
+
if (set.size > 0) return true;
|
|
330
|
+
}
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
/** Escape hatch (router integrations / tests). Mount-tracking already
|
|
334
|
+
* handles navigation, so this is rarely needed. */
|
|
335
|
+
reset() {
|
|
336
|
+
this._instances.clear();
|
|
337
|
+
}
|
|
338
|
+
/** Encode a resolved key into the internal id used by `_set`. */
|
|
339
|
+
encode(fullKey, defaultNamespace) {
|
|
340
|
+
const k = split(fullKey, defaultNamespace);
|
|
341
|
+
return `${k.namespace}${SEP}${k.key}`;
|
|
342
|
+
}
|
|
343
|
+
/** Provider mounted — publish the global (idempotent, ref-counted). */
|
|
344
|
+
attach() {
|
|
345
|
+
this._providers += 1;
|
|
346
|
+
if (this._providers === 1) {
|
|
347
|
+
globalThis[GLOBAL] = {
|
|
348
|
+
snapshot: () => this.snapshot(),
|
|
349
|
+
isPopulated: () => this.isPopulated(),
|
|
350
|
+
reset: () => this.reset()
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/** Provider unmounted — unpublish when the last one goes away. */
|
|
355
|
+
detach() {
|
|
356
|
+
this._providers = Math.max(0, this._providers - 1);
|
|
357
|
+
if (this._providers === 0) {
|
|
358
|
+
this._instances.clear();
|
|
359
|
+
const g = globalThis;
|
|
360
|
+
if (g[GLOBAL]) delete g[GLOBAL];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
var keyRegistry = new KeyRegistry();
|
|
365
|
+
|
|
366
|
+
// src/i18n.ts
|
|
367
|
+
var DEFAULT_API_BASE = "https://api.verbumia.dev";
|
|
368
|
+
var DEFAULT_CDN_BASE = "https://cdn.verbumia.ca";
|
|
369
|
+
var DEFAULT_FLUSH_MS = 5e3;
|
|
370
|
+
var DEFAULT_BATCH = 50;
|
|
371
|
+
var DEFAULT_BUFFER = 200;
|
|
372
|
+
var DEFAULT_VERSION_SLUG = "main";
|
|
373
|
+
function asArray(fb) {
|
|
374
|
+
if (fb == null) return [];
|
|
375
|
+
return (Array.isArray(fb) ? fb : [fb]).filter(Boolean);
|
|
376
|
+
}
|
|
377
|
+
var SonentaI18n = class {
|
|
378
|
+
ready = false;
|
|
379
|
+
locale;
|
|
380
|
+
fallbackLng;
|
|
381
|
+
missingEvents = [];
|
|
382
|
+
// The underlying real i18next instance (engine). Resolution/plurals/
|
|
383
|
+
// interpolation/context all run through this; the adapter only orchestrates
|
|
384
|
+
// loading + the missing-key gate + the React subscription.
|
|
385
|
+
_i18next;
|
|
386
|
+
// Original (native) i18next.changeLanguage, captured before we override the
|
|
387
|
+
// instance's changeLanguage to route through setLocale (#806 bug3). Used by
|
|
388
|
+
// _syncLanguage to switch the language WITHOUT re-entering our loader.
|
|
389
|
+
_origChangeLanguage;
|
|
390
|
+
// Token identifying THIS i18n instance in the on-screen key registry
|
|
391
|
+
// (#806 SeedSower instance-level producer — see _wrapInstanceT). Dropped
|
|
392
|
+
// in stop() so accumulated keys don't outlive a remounted provider.
|
|
393
|
+
_registryToken = /* @__PURE__ */ Symbol("sonenta.instance");
|
|
394
|
+
// Tracks which (version, locale, ns) we've actually fetched, and which came
|
|
395
|
+
// back with ≥1 top-level key. The missing-key gate needs `_hasContent`
|
|
396
|
+
// (200-with-content) to avoid the boot-flood when a namespace is unpublished
|
|
397
|
+
// or a fetch came back empty. i18next's own resource store can't carry this
|
|
398
|
+
// distinction (an empty {} bundle still `hasResourceBundle`).
|
|
399
|
+
_attempted = /* @__PURE__ */ new Set();
|
|
400
|
+
_hasContent = /* @__PURE__ */ new Set();
|
|
401
|
+
// Language catalog (#803): dir()/nativeName()/languageMeta(). Primed from
|
|
402
|
+
// config.languageCatalog and/or the public GET /v1/languages in start().
|
|
403
|
+
_catalog = new LanguageCatalog();
|
|
404
|
+
_catalogDisabled = false;
|
|
405
|
+
_config;
|
|
406
|
+
// Surface variant (#911). When set, each loaded (locale,ns) is composed as
|
|
407
|
+
// base ⊕ sparse `{ns}.{surface}.json` overlay (overlay wins per key). We
|
|
408
|
+
// cache the raw base tree + raw overlay trees so a surface switch recomposes
|
|
409
|
+
// WITHOUT refetching the base, and so a switch cleanly rebuilds base-then-
|
|
410
|
+
// overlay (no stale overlay keys). `undefined` ⇒ surface resolution off.
|
|
411
|
+
_surface;
|
|
412
|
+
_baseTree = /* @__PURE__ */ new Map();
|
|
413
|
+
// bundleKey -> raw base JSON
|
|
414
|
+
_overlayTree = /* @__PURE__ */ new Map();
|
|
415
|
+
// `${bundleKey}#${surface}` -> raw overlay JSON ({} sentinel on miss)
|
|
416
|
+
// Resolved asset refs (#911 minimal v1), keyed `${locale}/${ns}/${keyPath}`
|
|
417
|
+
// for the CURRENT composition (base assets, then overlay assets override).
|
|
418
|
+
_assets = /* @__PURE__ */ new Map();
|
|
419
|
+
_missing;
|
|
420
|
+
_listeners = /* @__PURE__ */ new Set();
|
|
421
|
+
// Stable snapshot reference for useSyncExternalStore. Rebuilt ONLY in _notify
|
|
422
|
+
// (when state actually changed) and returned as-is between notifications.
|
|
423
|
+
_snapshot;
|
|
424
|
+
// Effective key separator (#754): false = flat (literal lookups, so dotted
|
|
425
|
+
// keys like "App Version 6.3.8" work), a string = nested. Set from
|
|
426
|
+
// config.keySeparator (explicit override, wins) or auto-detected from the
|
|
427
|
+
// version's key_style/key_separator on start(). Defaults to ".".
|
|
428
|
+
_keySeparatorExplicit = false;
|
|
429
|
+
// Namespace separator (#754): false = no ns prefix parsing. Default ':'.
|
|
430
|
+
_nsSeparator = ":";
|
|
431
|
+
// Developer-supplied {{value, format}} formatter (#805-1), re-bound onto the
|
|
432
|
+
// live interpolator after init (i18next overwrites it during init).
|
|
433
|
+
_userFormat;
|
|
434
|
+
constructor(config) {
|
|
435
|
+
const removedRealtimeKeys = Object.keys(config).filter(
|
|
436
|
+
(k) => k === "liveUpdates" || k.startsWith("centrifugo")
|
|
437
|
+
);
|
|
438
|
+
if (removedRealtimeKeys.length > 0) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`@sonenta/react-i18next: ${removedRealtimeKeys.join(", ")} ${removedRealtimeKeys.length > 1 ? "were" : "was"} removed in 0.9.0 \u2014 realtime is now the @sonenta/realtime plugin. Remove them and pass \`plugins: [sonentaRealtime({ wsUrl })]\` to <SonentaProvider> instead.`
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
this.locale = config.defaultLocale;
|
|
444
|
+
this.fallbackLng = config.fallbackLng;
|
|
445
|
+
this._surface = config.surface;
|
|
446
|
+
let keySeparator = ".";
|
|
447
|
+
if (config.keySeparator !== void 0) {
|
|
448
|
+
keySeparator = config.keySeparator;
|
|
449
|
+
this._keySeparatorExplicit = true;
|
|
450
|
+
}
|
|
451
|
+
if (config.nsSeparator !== void 0) this._nsSeparator = config.nsSeparator;
|
|
452
|
+
this._config = {
|
|
453
|
+
apiBase: config.apiBase ?? DEFAULT_API_BASE,
|
|
454
|
+
cdnBase: config.cdnBase ?? DEFAULT_CDN_BASE,
|
|
455
|
+
missingHandler: config.missingHandler ?? "send",
|
|
456
|
+
token: config.token,
|
|
457
|
+
projectUuid: config.projectUuid,
|
|
458
|
+
namespaces: config.namespaces?.length ? config.namespaces : config.defaultNS ? [config.defaultNS] : ["common"],
|
|
459
|
+
flushIntervalMs: config.flushIntervalMs ?? DEFAULT_FLUSH_MS,
|
|
460
|
+
flushBatchSize: config.flushBatchSize ?? DEFAULT_BATCH,
|
|
461
|
+
missingEventsBufferSize: config.missingEventsBufferSize ?? DEFAULT_BUFFER,
|
|
462
|
+
version: config.version ?? config.versionSlug ?? DEFAULT_VERSION_SLUG,
|
|
463
|
+
env: config.env ?? "prod"
|
|
464
|
+
};
|
|
465
|
+
const transport = config.transport ?? (this._config.missingHandler === "log" ? logTransport : defaultTransport({
|
|
466
|
+
apiBase: this._config.apiBase,
|
|
467
|
+
token: this._config.token,
|
|
468
|
+
projectUuid: this._config.projectUuid
|
|
469
|
+
}));
|
|
470
|
+
this._missing = new MissingKeyManager({
|
|
471
|
+
transport,
|
|
472
|
+
missingHandler: this._config.missingHandler,
|
|
473
|
+
flushIntervalMs: this._config.flushIntervalMs,
|
|
474
|
+
flushBatchSize: this._config.flushBatchSize,
|
|
475
|
+
bufferSize: this._config.missingEventsBufferSize
|
|
476
|
+
});
|
|
477
|
+
this._userFormat = config.interpolation?.format;
|
|
478
|
+
const resources = {};
|
|
479
|
+
if (config.initialBundles) {
|
|
480
|
+
for (const [loc, byNs] of Object.entries(config.initialBundles)) {
|
|
481
|
+
for (const [ns2, tree] of Object.entries(byNs)) {
|
|
482
|
+
if (!tree || typeof tree !== "object") continue;
|
|
483
|
+
(resources[loc] ??= {})[ns2] = flattenPlurals(tree, loc);
|
|
484
|
+
this._baseTree.set(this._bundleKey(loc, ns2), tree);
|
|
485
|
+
if (Object.keys(tree).length > 0) {
|
|
486
|
+
this._hasContent.add(this._bundleKey(loc, ns2));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const interpolation = {
|
|
492
|
+
escapeValue: false,
|
|
493
|
+
format: this._userFormat
|
|
494
|
+
};
|
|
495
|
+
const ns = this._config.namespaces;
|
|
496
|
+
this._i18next = createInstance2();
|
|
497
|
+
this._i18next.use(initReactI18next2);
|
|
498
|
+
void this._i18next.init({
|
|
499
|
+
lng: this.locale,
|
|
500
|
+
fallbackLng: config.fallbackLng ?? false,
|
|
501
|
+
ns,
|
|
502
|
+
defaultNS: ns[0],
|
|
503
|
+
fallbackNS: false,
|
|
504
|
+
initAsync: false,
|
|
505
|
+
keySeparator,
|
|
506
|
+
nsSeparator: this._nsSeparator,
|
|
507
|
+
// #806 bug2: re-render react-i18next-native consumers when our async
|
|
508
|
+
// loads addResourceBundle (bindI18nStore catches 'added'/'removed'). The
|
|
509
|
+
// missing-key park is addResource SILENT, so it never emits 'added' →
|
|
510
|
+
// no re-render loop (the rc.1 forceStoreRerender-on-saveMissing regression).
|
|
511
|
+
react: {
|
|
512
|
+
bindI18n: "languageChanged loaded",
|
|
513
|
+
bindI18nStore: "added removed",
|
|
514
|
+
useSuspense: false
|
|
515
|
+
},
|
|
516
|
+
resources,
|
|
517
|
+
partialBundledLanguages: true,
|
|
518
|
+
// A nested-object (non-plural) key resolves to an object; legacy `t()`
|
|
519
|
+
// returns the raw key in that case, so map it back to the key here.
|
|
520
|
+
returnedObjectHandler: (key) => key,
|
|
521
|
+
// GATE (legacy three-condition gate): only report once the active
|
|
522
|
+
// locale's bundle exists AND has ≥1 key, AND we've flipped `ready` after a
|
|
523
|
+
// real fetch (`_attempted` + `_hasContent`). i18next fires the handler
|
|
524
|
+
// even for empty/absent bundles, so the filtering lives here.
|
|
525
|
+
saveMissing: this._config.missingHandler !== "off",
|
|
526
|
+
saveMissingTo: "all",
|
|
527
|
+
interpolation,
|
|
528
|
+
missingKeyHandler: this._handleMissing
|
|
529
|
+
});
|
|
530
|
+
this._origChangeLanguage = this._i18next.changeLanguage.bind(this._i18next);
|
|
531
|
+
this._i18next.changeLanguage = ((lng, ...rest) => {
|
|
532
|
+
if (typeof lng !== "string" || lng === this.locale) {
|
|
533
|
+
return this._origChangeLanguage(lng, ...rest);
|
|
534
|
+
}
|
|
535
|
+
return this.setLocale(lng).then(
|
|
536
|
+
() => this._i18next.getFixedT(this.locale, null)
|
|
537
|
+
);
|
|
538
|
+
});
|
|
539
|
+
this._wrapInstanceT();
|
|
540
|
+
this._rebindFormat();
|
|
541
|
+
this._catalogDisabled = config.disableLanguageCatalog === true;
|
|
542
|
+
this._catalog.merge(config.languageCatalog);
|
|
543
|
+
const active = config.initialBundles?.[this.locale];
|
|
544
|
+
if (active && this._config.namespaces.every(
|
|
545
|
+
(n) => active[n] && Object.keys(active[n]).length > 0
|
|
546
|
+
)) {
|
|
547
|
+
this.ready = true;
|
|
548
|
+
}
|
|
549
|
+
this._snapshot = this._buildSnapshot();
|
|
550
|
+
}
|
|
551
|
+
/** Re-bind the developer `{{value, format}}` formatter onto the live
|
|
552
|
+
* interpolator (i18next overwrites `interpolation.format` during init). */
|
|
553
|
+
_rebindFormat() {
|
|
554
|
+
const userFormat = this._userFormat;
|
|
555
|
+
if (!userFormat) return;
|
|
556
|
+
const interpolator = this._i18next.services?.interpolator;
|
|
557
|
+
if (!interpolator) return;
|
|
558
|
+
const builtIn = this._i18next.options.interpolation?.format;
|
|
559
|
+
interpolator.format = ((value, format, lng, opts) => {
|
|
560
|
+
const out = userFormat(
|
|
561
|
+
value,
|
|
562
|
+
format,
|
|
563
|
+
lng,
|
|
564
|
+
opts ?? {}
|
|
565
|
+
);
|
|
566
|
+
if (out === value && builtIn) {
|
|
567
|
+
return builtIn(value, format, lng, opts);
|
|
568
|
+
}
|
|
569
|
+
return out;
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
/** Monkey-patch `i18next.t` so every resolved key feeds the on-screen
|
|
573
|
+
* registry (#806). The override is an OWN property on the instance,
|
|
574
|
+
* shadowing the prototype `t()` method; getFixedT-returned t functions
|
|
575
|
+
* look up `this.t` dynamically (i18next v26 source ~line 2060), so
|
|
576
|
+
* native `react-i18next` `useTranslation` / `<Trans>` go through it
|
|
577
|
+
* too. Bookkeeping is wrapped in try/catch — a registry failure must
|
|
578
|
+
* never break translation. */
|
|
579
|
+
_wrapInstanceT() {
|
|
580
|
+
const i18n = this._i18next;
|
|
581
|
+
const orig = i18n.t.bind(i18n);
|
|
582
|
+
const token = this._registryToken;
|
|
583
|
+
const defaultNs = () => this.defaultNamespace;
|
|
584
|
+
const wrapped = ((...args) => {
|
|
585
|
+
const result = orig(...args);
|
|
586
|
+
try {
|
|
587
|
+
const rawKey = args[0];
|
|
588
|
+
const opts = args[1] && typeof args[1] === "object" && !Array.isArray(args[1]) ? args[1] : void 0;
|
|
589
|
+
let keyStr;
|
|
590
|
+
if (typeof rawKey === "string") {
|
|
591
|
+
keyStr = rawKey;
|
|
592
|
+
} else if (Array.isArray(rawKey) && typeof rawKey[0] === "string") {
|
|
593
|
+
keyStr = rawKey[0];
|
|
594
|
+
}
|
|
595
|
+
if (keyStr) {
|
|
596
|
+
const optsNs = opts?.ns;
|
|
597
|
+
const ns = typeof optsNs === "string" ? optsNs : Array.isArray(optsNs) ? optsNs[0] : void 0;
|
|
598
|
+
const fullKey = ns && !keyStr.includes(":") ? `${ns}:${keyStr}` : keyStr;
|
|
599
|
+
keyRegistry._track(token, keyRegistry.encode(fullKey, defaultNs()));
|
|
600
|
+
}
|
|
601
|
+
} catch {
|
|
602
|
+
}
|
|
603
|
+
return result;
|
|
604
|
+
});
|
|
605
|
+
i18n.t = wrapped;
|
|
606
|
+
}
|
|
607
|
+
// ---- React subscription ----
|
|
608
|
+
subscribe = (listener) => {
|
|
609
|
+
this._listeners.add(listener);
|
|
610
|
+
return () => this._listeners.delete(listener);
|
|
611
|
+
};
|
|
612
|
+
/** Stable snapshot accessor for useSyncExternalStore. The returned object
|
|
613
|
+
* reference is identical between renders unless _notify fired. */
|
|
614
|
+
getSnapshot = () => this._snapshot;
|
|
615
|
+
_buildSnapshot() {
|
|
616
|
+
return {
|
|
617
|
+
ready: this.ready,
|
|
618
|
+
locale: this.locale,
|
|
619
|
+
language: this.locale,
|
|
620
|
+
setLocale: this.setLocale,
|
|
621
|
+
changeLanguage: this.changeLanguage,
|
|
622
|
+
t: this.t,
|
|
623
|
+
missingEvents: this.missingEvents,
|
|
624
|
+
flushMissing: this.flushMissing,
|
|
625
|
+
reload: this.reload,
|
|
626
|
+
refresh: this.refresh,
|
|
627
|
+
surface: this._surface,
|
|
628
|
+
setSurface: this.setSurface,
|
|
629
|
+
asset: this.asset,
|
|
630
|
+
dir: this.dir,
|
|
631
|
+
nativeName: this.nativeName,
|
|
632
|
+
languageMeta: this.languageMeta,
|
|
633
|
+
i18next: this._i18next
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
/** Bundle cache-key builder. Includes `version` so providers with different
|
|
637
|
+
* `version` values never share state. Segments are slugs (no '/'). */
|
|
638
|
+
_bundleKey(locale, ns) {
|
|
639
|
+
return `${this._config.version}/${locale}/${ns}`;
|
|
640
|
+
}
|
|
641
|
+
_notify() {
|
|
642
|
+
this._snapshot = this._buildSnapshot();
|
|
643
|
+
for (const l of this._listeners) l();
|
|
644
|
+
}
|
|
645
|
+
/** Force react-i18next-NATIVE consumers (raw `useTranslation`/`<Trans>` on the
|
|
646
|
+
* exposed instance) to re-render in the READY state, by emitting i18next's
|
|
647
|
+
* `loaded` event (which react-i18next binds via `bindI18n`). Called AFTER
|
|
648
|
+
* `ready=true`, so a missing key they render is re-evaluated once the gate is
|
|
649
|
+
* open → it streams to `/v1/missing` (#806 native path; our own
|
|
650
|
+
* `useTranslation` already re-renders via `_notify`). The missing-key park is
|
|
651
|
+
* a SILENT addResource, so this never reopens the render loop (rc.1). */
|
|
652
|
+
_signalLoaded() {
|
|
653
|
+
this._i18next.emit("loaded", {});
|
|
654
|
+
}
|
|
655
|
+
// ---- Lifecycle ----
|
|
656
|
+
/** Default namespace (the first configured one) — used to attribute a bare
|
|
657
|
+
* `t("key")` call when recording on-screen keys. */
|
|
658
|
+
get defaultNamespace() {
|
|
659
|
+
return this._config.namespaces[0];
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Ordered lookup chain for an active locale: the locale itself, its
|
|
663
|
+
* base-language truncations (`fr-CA → fr`), then the configured
|
|
664
|
+
* `fallbackLng`(s) — deduped, order-preserving (the `fr-CA → fr → source`
|
|
665
|
+
* chain, #803). Used to drive bundle loading; i18next handles resolution
|
|
666
|
+
* fallback itself via its `languages` list.
|
|
667
|
+
*/
|
|
668
|
+
_resolutionChain(locale) {
|
|
669
|
+
const out = [];
|
|
670
|
+
const seen = /* @__PURE__ */ new Set();
|
|
671
|
+
for (const l of [...localeChain(locale), ...asArray(this.fallbackLng)]) {
|
|
672
|
+
if (l && !seen.has(l)) {
|
|
673
|
+
seen.add(l);
|
|
674
|
+
out.push(l);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return out;
|
|
678
|
+
}
|
|
679
|
+
/** Loads the configured namespaces for the active locale's full resolution
|
|
680
|
+
* chain (locale → base → fallback), wires up the key separator + catalog,
|
|
681
|
+
* flips `ready`, and arms the missing-key flush loop. */
|
|
682
|
+
async start(fetchImpl = fetch) {
|
|
683
|
+
keyRegistry.attach();
|
|
684
|
+
const targets = new Set(this._resolutionChain(this.locale));
|
|
685
|
+
await Promise.all([
|
|
686
|
+
...[...targets].flatMap(
|
|
687
|
+
(loc) => this._config.namespaces.map(
|
|
688
|
+
(ns) => this._loadBundle(loc, ns, fetchImpl)
|
|
689
|
+
)
|
|
690
|
+
),
|
|
691
|
+
// Best-effort: align the key separator with the version's key_style (#754).
|
|
692
|
+
this._loadKeyStyle(fetchImpl),
|
|
693
|
+
// Best-effort: load the public language catalog for dir()/nativeName().
|
|
694
|
+
this._loadCatalog(fetchImpl)
|
|
695
|
+
]);
|
|
696
|
+
await this._syncLanguage();
|
|
697
|
+
this.ready = true;
|
|
698
|
+
this._missing.start();
|
|
699
|
+
this._notify();
|
|
700
|
+
this._signalLoaded();
|
|
701
|
+
}
|
|
702
|
+
/** Best-effort: read the version's `key_style` / `key_separator` (#754) so the
|
|
703
|
+
* SDK resolves keys the way the project's bundles are shaped. Skipped when
|
|
704
|
+
* the dev set `keySeparator` explicitly; on 403/404/offline keep the default. */
|
|
705
|
+
async _loadKeyStyle(fetchImpl) {
|
|
706
|
+
if (this._keySeparatorExplicit) return;
|
|
707
|
+
const sep = await detectKeySeparator(
|
|
708
|
+
this._config.apiBase,
|
|
709
|
+
this._config.projectUuid,
|
|
710
|
+
this._config.version,
|
|
711
|
+
this._config.token,
|
|
712
|
+
fetchImpl
|
|
713
|
+
);
|
|
714
|
+
this._i18next.options.keySeparator = sep;
|
|
715
|
+
}
|
|
716
|
+
/** Best-effort: fetch the PUBLIC language catalog and merge it in (#803).
|
|
717
|
+
* Skipped when disabled; a failure keeps any embedded catalog. */
|
|
718
|
+
async _loadCatalog(fetchImpl) {
|
|
719
|
+
if (this._catalogDisabled) return;
|
|
720
|
+
this._catalog.merge(await loadCatalog(this._config.apiBase, fetchImpl));
|
|
721
|
+
}
|
|
722
|
+
/** Re-derive i18next's active language chain from the adapter's locale +
|
|
723
|
+
* fallbackLng. `changeLanguage` recomputes `instance.languages`
|
|
724
|
+
* (variant→base→fallback) so native resolution fallback is correct. */
|
|
725
|
+
async _syncLanguage() {
|
|
726
|
+
if (this._i18next.language !== this.locale) {
|
|
727
|
+
await this._origChangeLanguage(this.locale);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
setLocale = async (next) => {
|
|
731
|
+
if (next === this.locale) return;
|
|
732
|
+
this.locale = next;
|
|
733
|
+
this.ready = false;
|
|
734
|
+
this._notify();
|
|
735
|
+
await Promise.all(
|
|
736
|
+
this._resolutionChain(next).flatMap(
|
|
737
|
+
(loc) => this._config.namespaces.filter((ns) => !this._attempted.has(this._bundleKey(loc, ns))).map((ns) => this._loadBundle(loc, ns))
|
|
738
|
+
)
|
|
739
|
+
);
|
|
740
|
+
await this._syncLanguage();
|
|
741
|
+
this.ready = true;
|
|
742
|
+
this._notify();
|
|
743
|
+
this._signalLoaded();
|
|
744
|
+
};
|
|
745
|
+
/** Alias of {@link setLocale} for react-i18next compatibility. */
|
|
746
|
+
changeLanguage = (next) => this.setLocale(next);
|
|
747
|
+
/** Alias of {@link locale} for react-i18next compatibility. */
|
|
748
|
+
get language() {
|
|
749
|
+
return this.locale;
|
|
750
|
+
}
|
|
751
|
+
/** The underlying real `i18next` instance (#806 bug1 / #805 drop-in). Exposed
|
|
752
|
+
* on the class too (not just the snapshot) so `getI18n().i18next` works for
|
|
753
|
+
* react-i18next drop-in (`<Trans i18n={…}>`, `useTranslation(ns, { i18n })`). */
|
|
754
|
+
get i18next() {
|
|
755
|
+
return this._i18next;
|
|
756
|
+
}
|
|
757
|
+
/** Text direction for a locale (default: active locale) — i18next parity.
|
|
758
|
+
* Catalog `rtl` is authoritative (variant→base); falls back to the built-in
|
|
759
|
+
* RTL list before/without the catalog (#803). */
|
|
760
|
+
dir = (lng) => this._catalog.dir(lng ?? this.locale);
|
|
761
|
+
/** Endonym (native name) for a locale from the catalog (default: active) —
|
|
762
|
+
* the fallback for runtimes without `Intl.DisplayNames` (#803). */
|
|
763
|
+
nativeName = (lng) => this._catalog.nativeName(lng ?? this.locale);
|
|
764
|
+
/** Full language-catalog entry for a locale (default: active); `undefined`
|
|
765
|
+
* when the catalog has no matching entry. */
|
|
766
|
+
languageMeta = (lng) => this._catalog.languageMeta(lng ?? this.locale);
|
|
767
|
+
stop() {
|
|
768
|
+
keyRegistry._delete(this._registryToken);
|
|
769
|
+
keyRegistry.detach();
|
|
770
|
+
this._missing.stop();
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* #806 plugin-context backing for `SonentaPluginContext.onLanguageChange`.
|
|
774
|
+
* Subscribes to i18next's `languageChanged` emitter and returns the
|
|
775
|
+
* unsubscribe function. Wraps the i18next event so plugins never have
|
|
776
|
+
* to touch the (prefix-private) `_i18next` field directly. Fires on
|
|
777
|
+
* every successful language change — `setLocale()` /
|
|
778
|
+
* `changeLanguage()` / the drop-in `i18next.changeLanguage()` (whose
|
|
779
|
+
* #806-bug3 override routes through `setLocale` which then runs the
|
|
780
|
+
* native `_origChangeLanguage` that actually fires the event).
|
|
781
|
+
*/
|
|
782
|
+
onLanguageChange = (cb) => {
|
|
783
|
+
const handler = (lng) => cb(lng);
|
|
784
|
+
this._i18next.on("languageChanged", handler);
|
|
785
|
+
return () => {
|
|
786
|
+
this._i18next.off("languageChanged", handler);
|
|
787
|
+
};
|
|
788
|
+
};
|
|
789
|
+
/**
|
|
790
|
+
* Bust-refetch already-loaded bundles and re-render once. Iterate the
|
|
791
|
+
* `_attempted` cache keys (`${version}/${locale}/${ns}`), optionally filtered
|
|
792
|
+
* by `opts.locale` / `opts.namespace`, and re-pull each with `{ bust: true }`
|
|
793
|
+
* (so the mutable CDN `latest/` alias bypasses the HTTP cache). After all
|
|
794
|
+
* settle, `_notify()` once so React re-renders. Used by `@sonenta/realtime`
|
|
795
|
+
* on a `translations_published` push and as a manual refresh hook.
|
|
796
|
+
*/
|
|
797
|
+
reload = async (opts = {}) => {
|
|
798
|
+
const targets = [];
|
|
799
|
+
for (const key of this._attempted) {
|
|
800
|
+
const parts = key.split("/");
|
|
801
|
+
const locale = parts[1];
|
|
802
|
+
const ns = parts[2];
|
|
803
|
+
if (!locale || !ns) continue;
|
|
804
|
+
if (opts.locale && opts.locale !== locale) continue;
|
|
805
|
+
if (opts.namespace && opts.namespace !== ns) continue;
|
|
806
|
+
targets.push({ locale, ns });
|
|
807
|
+
}
|
|
808
|
+
if (targets.length === 0) return;
|
|
809
|
+
await Promise.all(
|
|
810
|
+
targets.map(
|
|
811
|
+
(t2) => this._loadBundle(t2.locale, t2.ns, fetch, { bust: true })
|
|
812
|
+
)
|
|
813
|
+
);
|
|
814
|
+
this._notify();
|
|
815
|
+
};
|
|
816
|
+
/**
|
|
817
|
+
* Force every `useTranslation` consumer to re-render WITHOUT refetching —
|
|
818
|
+
* re-resolves `t()` against the CURRENT resources. For plugins that mutate
|
|
819
|
+
* i18next resources directly (e.g. `@sonenta/in-context` applies an edit
|
|
820
|
+
* via `i18next.addResource`) and need the snapshot to repaint right away.
|
|
821
|
+
* Repaints BOTH this SDK's `useTranslation`/`<Trans>` (via the store
|
|
822
|
+
* `_notify`) AND react-i18next-NATIVE consumers bound to the exposed
|
|
823
|
+
* instance (via the `loaded` event). Does NO bundle fetch, so an in-place
|
|
824
|
+
* override is never clobbered — unlike {@link reload}, which bust-refetches
|
|
825
|
+
* the CDN bundles. Additive (1.0.6); safe to call any time after mount.
|
|
826
|
+
*/
|
|
827
|
+
refresh = () => {
|
|
828
|
+
this._notify();
|
|
829
|
+
this._signalLoaded();
|
|
830
|
+
};
|
|
831
|
+
get surface() {
|
|
832
|
+
return this._surface;
|
|
833
|
+
}
|
|
834
|
+
/** Switch the active surface (#911) and recompose every loaded (locale, ns)
|
|
835
|
+
* as base ⊕ the new surface's overlay, then re-render. `undefined` drops to
|
|
836
|
+
* base-only. No-op when unchanged. Overlays are fetched on demand + cached. */
|
|
837
|
+
setSurface = async (surface) => {
|
|
838
|
+
if (surface === this._surface) return;
|
|
839
|
+
this._surface = surface;
|
|
840
|
+
const targets = [];
|
|
841
|
+
for (const key of this._attempted) {
|
|
842
|
+
const parts = key.split("/");
|
|
843
|
+
const locale = parts[1];
|
|
844
|
+
const ns = parts[2];
|
|
845
|
+
if (locale && ns) targets.push({ locale, ns });
|
|
846
|
+
}
|
|
847
|
+
await Promise.all(
|
|
848
|
+
targets.map((t2) => this._composeBundle(t2.locale, t2.ns))
|
|
849
|
+
);
|
|
850
|
+
this._notify();
|
|
851
|
+
this._signalLoaded();
|
|
852
|
+
};
|
|
853
|
+
/** Asset variant ref (#911) for `key` under the active locale+surface, or
|
|
854
|
+
* `undefined`. Walks the locale fallback chain like `t()` would. */
|
|
855
|
+
asset = (key, namespace) => {
|
|
856
|
+
let ns = namespace ?? this.defaultNamespace;
|
|
857
|
+
let bareKey = key;
|
|
858
|
+
if (typeof this._nsSeparator === "string" && this._nsSeparator) {
|
|
859
|
+
const idx = key.indexOf(this._nsSeparator);
|
|
860
|
+
if (idx > 0) {
|
|
861
|
+
ns = key.slice(0, idx);
|
|
862
|
+
bareKey = key.slice(idx + this._nsSeparator.length);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
for (const loc of this._resolutionChain(this.locale)) {
|
|
866
|
+
const ref = this._assets.get(`${loc}/${ns}/${bareKey}`);
|
|
867
|
+
if (ref) return ref;
|
|
868
|
+
}
|
|
869
|
+
return void 0;
|
|
870
|
+
};
|
|
871
|
+
// ---- Translation ----
|
|
872
|
+
t = (key, optionsOrDefault, maybeOptions) => {
|
|
873
|
+
const options = typeof optionsOrDefault === "string" ? { ...maybeOptions ?? {}, defaultValue: optionsOrDefault } : optionsOrDefault;
|
|
874
|
+
const literal = this._probeLiteral(key);
|
|
875
|
+
if (literal !== void 0) {
|
|
876
|
+
const interpolator = this._i18next.services?.interpolator;
|
|
877
|
+
if (interpolator && options) {
|
|
878
|
+
return interpolator.interpolate(
|
|
879
|
+
literal,
|
|
880
|
+
options,
|
|
881
|
+
this.locale,
|
|
882
|
+
{}
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
return literal;
|
|
886
|
+
}
|
|
887
|
+
return this._i18next.t(
|
|
888
|
+
key,
|
|
889
|
+
options
|
|
890
|
+
);
|
|
891
|
+
};
|
|
892
|
+
/** Probe the EXACT literal value for a key across the active language chain
|
|
893
|
+
* (no key split), honoring the configured nsSeparator to point at the right
|
|
894
|
+
* (lng, ns). Returns the string hit, or `undefined` to fall through to the
|
|
895
|
+
* native nested/plural resolution. Mirrors `sonentaResolveKey`'s probe but
|
|
896
|
+
* is local so the adapter can interpolate the result. */
|
|
897
|
+
_probeLiteral(key) {
|
|
898
|
+
const nsSeparator = this._i18next.options.nsSeparator;
|
|
899
|
+
let probeNs = this.defaultNamespace;
|
|
900
|
+
let probeKey = key;
|
|
901
|
+
if (typeof nsSeparator === "string" && nsSeparator !== "") {
|
|
902
|
+
const idx = key.indexOf(nsSeparator);
|
|
903
|
+
if (idx > 0) {
|
|
904
|
+
probeNs = key.slice(0, idx);
|
|
905
|
+
probeKey = key.slice(idx + nsSeparator.length);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
const langs = this._i18next.languages ?? [this._i18next.language].filter(Boolean);
|
|
909
|
+
for (const lng of langs) {
|
|
910
|
+
if (!lng) continue;
|
|
911
|
+
const v = this._i18next.getResource(lng, probeNs, probeKey, {
|
|
912
|
+
keySeparator: false
|
|
913
|
+
});
|
|
914
|
+
if (typeof v === "string") return v;
|
|
915
|
+
}
|
|
916
|
+
return void 0;
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* i18next `missingKeyHandler`: the legacy three-condition gate + #746
|
|
920
|
+
* source_value rule. Only report once we've fetched the active locale's
|
|
921
|
+
* bundle (`ready` + `_attempted` + `_hasContent`), so the first paint /
|
|
922
|
+
* an unpublished namespace / a 404→{} / 200→{} never floods the dashboard.
|
|
923
|
+
* `fallbackValue` is ignored — when no defaultValue was passed it === key,
|
|
924
|
+
* which must NEVER become a source_value.
|
|
925
|
+
*/
|
|
926
|
+
_handleMissing = (lngs, ns, key, _fallbackValue, _updateMissing, options) => {
|
|
927
|
+
void _fallbackValue;
|
|
928
|
+
void _updateMissing;
|
|
929
|
+
const lng = lngs[0] ?? this.locale;
|
|
930
|
+
const cacheKey = this._bundleKey(this.locale, ns);
|
|
931
|
+
if (!this.ready || !this._attempted.has(cacheKey) || !this._hasContent.has(cacheKey)) {
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const recorded = this._missing.record({
|
|
935
|
+
key,
|
|
936
|
+
namespace: ns,
|
|
937
|
+
language_code: lng,
|
|
938
|
+
source_value: this._sourceValueFor(key, ns, options)
|
|
939
|
+
});
|
|
940
|
+
if (!recorded) return;
|
|
941
|
+
const rendered = typeof options?.defaultValue === "string" ? options.defaultValue : key;
|
|
942
|
+
try {
|
|
943
|
+
this._i18next.addResource(lng, ns, key, rendered, {
|
|
944
|
+
keySeparator: false,
|
|
945
|
+
silent: true
|
|
946
|
+
});
|
|
947
|
+
} catch {
|
|
948
|
+
}
|
|
949
|
+
this.missingEvents = this._missing.missingEvents;
|
|
950
|
+
this._notify();
|
|
951
|
+
};
|
|
952
|
+
flushMissing = () => this._missing.flush();
|
|
953
|
+
// ---- Internals ----
|
|
954
|
+
/**
|
|
955
|
+
* Resolve the `source_value` for a missing-key report (Option A, #746):
|
|
956
|
+
* 1. `options.defaultValue` — explicit developer-provided string.
|
|
957
|
+
* 2. The configured fallbackLng bundle's plain-string value (the
|
|
958
|
+
* source/canonical locale). Excludes the active locale + its derived
|
|
959
|
+
* base (display fallbacks, not the promotable "source").
|
|
960
|
+
* 3. Otherwise `undefined` — we never send the key name as a value (the key
|
|
961
|
+
* already travels in `event.key`).
|
|
962
|
+
*/
|
|
963
|
+
_sourceValueFor(key, ns, options) {
|
|
964
|
+
if (typeof options?.defaultValue === "string") {
|
|
965
|
+
return options.defaultValue;
|
|
966
|
+
}
|
|
967
|
+
for (const loc of asArray(this.fallbackLng)) {
|
|
968
|
+
if (loc === this.locale) continue;
|
|
969
|
+
const v = this._i18next.getResource(loc, ns, key);
|
|
970
|
+
if (typeof v === "string") return v;
|
|
971
|
+
}
|
|
972
|
+
return void 0;
|
|
973
|
+
}
|
|
974
|
+
async _loadBundle(locale, ns, fetchImpl = fetch, opts = {}) {
|
|
975
|
+
const cacheKey = this._bundleKey(locale, ns);
|
|
976
|
+
let url;
|
|
977
|
+
let init;
|
|
978
|
+
if (this._config.env === "dev") {
|
|
979
|
+
const qp = { language: locale, namespace: ns };
|
|
980
|
+
if (this._config.version && this._config.version !== "main") {
|
|
981
|
+
qp.version_slug = this._config.version;
|
|
982
|
+
}
|
|
983
|
+
const params = new URLSearchParams(qp);
|
|
984
|
+
url = `${this._config.apiBase.replace(/\/+$/, "")}/v1/projects/${this._config.projectUuid}/translations/runtime?${params.toString()}`;
|
|
985
|
+
init = {
|
|
986
|
+
method: "GET",
|
|
987
|
+
headers: { Authorization: `ApiKey ${this._config.token}` },
|
|
988
|
+
credentials: "omit"
|
|
989
|
+
};
|
|
990
|
+
} else {
|
|
991
|
+
url = `${this._config.cdnBase.replace(/\/+$/, "")}/p/${this._config.projectUuid}/${this._config.version}/latest/${locale}/${ns}.json`;
|
|
992
|
+
init = { method: "GET", credentials: "omit" };
|
|
993
|
+
}
|
|
994
|
+
if (opts.bust) {
|
|
995
|
+
init.cache = "reload";
|
|
996
|
+
}
|
|
997
|
+
const hadContent = this._hasContent.has(cacheKey);
|
|
998
|
+
try {
|
|
999
|
+
const r = await fetchImpl(url, init);
|
|
1000
|
+
if (r.ok) {
|
|
1001
|
+
const data = await r.json();
|
|
1002
|
+
if (data && typeof data === "object" && Object.keys(data).length > 0) {
|
|
1003
|
+
this._baseTree.set(cacheKey, data);
|
|
1004
|
+
this._hasContent.add(cacheKey);
|
|
1005
|
+
} else {
|
|
1006
|
+
this._baseTree.set(cacheKey, {});
|
|
1007
|
+
this._hasContent.delete(cacheKey);
|
|
1008
|
+
}
|
|
1009
|
+
} else if (hadContent) {
|
|
1010
|
+
} else {
|
|
1011
|
+
this._baseTree.set(cacheKey, {});
|
|
1012
|
+
this._hasContent.delete(cacheKey);
|
|
1013
|
+
}
|
|
1014
|
+
} catch {
|
|
1015
|
+
if (hadContent) {
|
|
1016
|
+
} else {
|
|
1017
|
+
this._baseTree.set(cacheKey, {});
|
|
1018
|
+
this._hasContent.delete(cacheKey);
|
|
1019
|
+
}
|
|
1020
|
+
} finally {
|
|
1021
|
+
this._attempted.add(cacheKey);
|
|
1022
|
+
}
|
|
1023
|
+
await this._composeBundle(locale, ns, fetchImpl, opts.bust);
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Compose the i18next bundle for (locale, ns) as base ⊕ surface overlay
|
|
1027
|
+
* (#911). Base is applied wholesale (replace), then — when a surface is
|
|
1028
|
+
* active — the sparse overlay is deep-merged on top (overlay wins per key).
|
|
1029
|
+
* Rebuilding base-first on every compose means a surface SWITCH cleanly
|
|
1030
|
+
* resets to base before applying the new overlay (no stale overlay keys).
|
|
1031
|
+
* Plurals: both base and overlay flatten through {@link flattenPlurals}, so
|
|
1032
|
+
* an overlay plural dict fully replaces the base key's plural set.
|
|
1033
|
+
*/
|
|
1034
|
+
async _composeBundle(locale, ns, fetchImpl = fetch, bust = false) {
|
|
1035
|
+
const base = this._baseTree.get(this._bundleKey(locale, ns)) ?? {};
|
|
1036
|
+
const assetPrefix = `${locale}/${ns}/`;
|
|
1037
|
+
for (const k of this._assets.keys()) {
|
|
1038
|
+
if (k.startsWith(assetPrefix)) this._assets.delete(k);
|
|
1039
|
+
}
|
|
1040
|
+
const baseTree = this._unwrapAssets(base, locale, ns);
|
|
1041
|
+
this._i18next.addResourceBundle(
|
|
1042
|
+
locale,
|
|
1043
|
+
ns,
|
|
1044
|
+
flattenPlurals(baseTree, locale),
|
|
1045
|
+
false,
|
|
1046
|
+
true
|
|
1047
|
+
);
|
|
1048
|
+
if (!this._surface) return;
|
|
1049
|
+
const overlay = await this._loadOverlay(
|
|
1050
|
+
locale,
|
|
1051
|
+
ns,
|
|
1052
|
+
this._surface,
|
|
1053
|
+
fetchImpl,
|
|
1054
|
+
bust
|
|
1055
|
+
);
|
|
1056
|
+
if (!overlay || Object.keys(overlay).length === 0) return;
|
|
1057
|
+
const overlayTree = this._unwrapAssets(overlay, locale, ns);
|
|
1058
|
+
this._i18next.addResourceBundle(
|
|
1059
|
+
locale,
|
|
1060
|
+
ns,
|
|
1061
|
+
flattenPlurals(overlayTree, locale),
|
|
1062
|
+
true,
|
|
1063
|
+
true
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
/** Fetch (and cache) the sparse surface overlay `{ns}.{surface}.json` from
|
|
1067
|
+
* the CDN. Cached per (locale, ns, surface); a `{}` is cached on miss so a
|
|
1068
|
+
* surface switch never re-hits the network for a known-absent overlay.
|
|
1069
|
+
* Overlays live on the CDN only (the dev runtime endpoint has no overlay
|
|
1070
|
+
* shape yet) — `env: "dev"` returns `{}`. */
|
|
1071
|
+
async _loadOverlay(locale, ns, surface, fetchImpl = fetch, bust = false) {
|
|
1072
|
+
const key = `${this._bundleKey(locale, ns)}#${surface}`;
|
|
1073
|
+
if (!bust && this._overlayTree.has(key)) return this._overlayTree.get(key);
|
|
1074
|
+
if (this._config.env === "dev") {
|
|
1075
|
+
this._overlayTree.set(key, {});
|
|
1076
|
+
return {};
|
|
1077
|
+
}
|
|
1078
|
+
const url = `${this._config.cdnBase.replace(/\/+$/, "")}/p/${this._config.projectUuid}/${this._config.version}/latest/${locale}/${ns}.${surface}.json`;
|
|
1079
|
+
const init = { method: "GET", credentials: "omit" };
|
|
1080
|
+
if (bust) init.cache = "reload";
|
|
1081
|
+
let overlay = {};
|
|
1082
|
+
try {
|
|
1083
|
+
const r = await fetchImpl(url, init);
|
|
1084
|
+
if (r.ok) {
|
|
1085
|
+
const data = await r.json();
|
|
1086
|
+
if (data && typeof data === "object") overlay = data;
|
|
1087
|
+
}
|
|
1088
|
+
} catch {
|
|
1089
|
+
}
|
|
1090
|
+
this._overlayTree.set(key, overlay);
|
|
1091
|
+
return overlay;
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Strip `{ "$value", "$asset" }` asset envelopes (#911 minimal v1) out of a
|
|
1095
|
+
* raw bundle tree: each envelope is replaced by its `$value` (string or
|
|
1096
|
+
* plural dict) so `t()` resolves normally, and its `$asset` is recorded
|
|
1097
|
+
* under `${locale}/${ns}/${keyPath}` for {@link asset}. Returns a NEW tree;
|
|
1098
|
+
* the input is not mutated. Non-envelope nodes recurse as namespace groups.
|
|
1099
|
+
*/
|
|
1100
|
+
_unwrapAssets(tree, locale, ns) {
|
|
1101
|
+
const sep = typeof this._i18next.options.keySeparator === "string" ? this._i18next.options.keySeparator : ".";
|
|
1102
|
+
const walk = (node, path) => {
|
|
1103
|
+
if (!node || typeof node !== "object") return node;
|
|
1104
|
+
const obj = node;
|
|
1105
|
+
if (Object.prototype.hasOwnProperty.call(obj, "$value")) {
|
|
1106
|
+
const a = obj.$asset;
|
|
1107
|
+
if (a && typeof a.kind === "string" && typeof a.ref === "string") {
|
|
1108
|
+
this._assets.set(`${locale}/${ns}/${path.join(sep)}`, {
|
|
1109
|
+
kind: a.kind,
|
|
1110
|
+
ref: a.ref
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
return obj.$value;
|
|
1114
|
+
}
|
|
1115
|
+
const out = {};
|
|
1116
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
1117
|
+
out[k] = walk(v, [...path, k]);
|
|
1118
|
+
}
|
|
1119
|
+
return out;
|
|
1120
|
+
};
|
|
1121
|
+
return walk(tree, []);
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
// src/singleton.ts
|
|
1126
|
+
var _active = null;
|
|
1127
|
+
function _setActiveInstance(instance) {
|
|
1128
|
+
_active = instance;
|
|
1129
|
+
}
|
|
1130
|
+
function _clearActiveInstance(instance) {
|
|
1131
|
+
if (_active === instance) _active = null;
|
|
1132
|
+
}
|
|
1133
|
+
function getI18n() {
|
|
1134
|
+
if (!_active) {
|
|
1135
|
+
throw new Error(
|
|
1136
|
+
"@sonenta/react-i18next: getI18n() was called before <SonentaProvider> mounted (no active i18n instance)."
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
return _active;
|
|
1140
|
+
}
|
|
1141
|
+
function getI18nSafe() {
|
|
1142
|
+
return _active;
|
|
1143
|
+
}
|
|
1144
|
+
function t(key, optionsOrDefault, maybeOptions) {
|
|
1145
|
+
if (_active) return _active.t(key, optionsOrDefault, maybeOptions);
|
|
1146
|
+
const dv = typeof optionsOrDefault === "string" ? optionsOrDefault : optionsOrDefault?.defaultValue;
|
|
1147
|
+
return typeof dv === "string" ? dv : key;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// src/surface.ts
|
|
1151
|
+
var DEFAULT_SURFACE_BREAKPOINTS = {
|
|
1152
|
+
mobile: 640,
|
|
1153
|
+
tablet: 1024
|
|
1154
|
+
};
|
|
1155
|
+
function surfaceForWidth(width, breakpoints = DEFAULT_SURFACE_BREAKPOINTS) {
|
|
1156
|
+
if (width < breakpoints.mobile) return "mobile";
|
|
1157
|
+
if (width < breakpoints.tablet) return "tablet";
|
|
1158
|
+
return "desktop";
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// src/provider.tsx
|
|
1162
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
1163
|
+
var SonentaContext = createContext(null);
|
|
1164
|
+
function SonentaProvider({
|
|
1165
|
+
children,
|
|
1166
|
+
...config
|
|
1167
|
+
}) {
|
|
1168
|
+
const i18n = useMemo(() => new SonentaI18n(config), []);
|
|
1169
|
+
useEffect(() => {
|
|
1170
|
+
_setActiveInstance(i18n);
|
|
1171
|
+
void i18n.start();
|
|
1172
|
+
const teardowns = (config.plugins ?? []).map(
|
|
1173
|
+
(p) => p.setup?.({
|
|
1174
|
+
i18n,
|
|
1175
|
+
config,
|
|
1176
|
+
onLanguageChange: i18n.onLanguageChange
|
|
1177
|
+
})
|
|
1178
|
+
).filter((t2) => typeof t2 === "function");
|
|
1179
|
+
return () => {
|
|
1180
|
+
teardowns.forEach((t2) => t2());
|
|
1181
|
+
i18n.stop();
|
|
1182
|
+
_clearActiveInstance(i18n);
|
|
1183
|
+
};
|
|
1184
|
+
}, [i18n]);
|
|
1185
|
+
useEffect(() => {
|
|
1186
|
+
if (!config.surface || !config.surfaceBreakpoints) return;
|
|
1187
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function")
|
|
1188
|
+
return;
|
|
1189
|
+
const bp = config.surfaceBreakpoints === true ? DEFAULT_SURFACE_BREAKPOINTS : config.surfaceBreakpoints;
|
|
1190
|
+
const sync = () => void i18n.setSurface(surfaceForWidth(window.innerWidth, bp));
|
|
1191
|
+
sync();
|
|
1192
|
+
const mqls = [bp.mobile, bp.tablet].map(
|
|
1193
|
+
(px) => window.matchMedia(`(min-width: ${px}px)`)
|
|
1194
|
+
);
|
|
1195
|
+
mqls.forEach((mql) => mql.addEventListener("change", sync));
|
|
1196
|
+
return () => mqls.forEach((mql) => mql.removeEventListener("change", sync));
|
|
1197
|
+
}, [i18n]);
|
|
1198
|
+
const value = useMemo(() => ({ i18n }), [i18n]);
|
|
1199
|
+
return /* @__PURE__ */ jsxs(SonentaContext.Provider, { value, children: [
|
|
1200
|
+
children,
|
|
1201
|
+
(config.plugins ?? []).map(
|
|
1202
|
+
(p) => p.render ? /* @__PURE__ */ jsx(Fragment, { children: p.render() }, p.name) : null
|
|
1203
|
+
)
|
|
1204
|
+
] });
|
|
1205
|
+
}
|
|
1206
|
+
function useI18n() {
|
|
1207
|
+
const ctx = useContext(SonentaContext);
|
|
1208
|
+
if (!ctx) {
|
|
1209
|
+
throw new Error("useTranslation/Trans must be used inside <SonentaProvider>");
|
|
1210
|
+
}
|
|
1211
|
+
return ctx.i18n;
|
|
1212
|
+
}
|
|
1213
|
+
function useI18nSnapshot() {
|
|
1214
|
+
const i18n = useI18n();
|
|
1215
|
+
return useSyncExternalStore(i18n.subscribe, i18n.getSnapshot, i18n.getSnapshot);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// src/hooks.ts
|
|
1219
|
+
import { useEffect as useEffect2, useMemo as useMemo2, useRef } from "react";
|
|
1220
|
+
function useTranslation(defaultNamespace) {
|
|
1221
|
+
const i18n = useI18n();
|
|
1222
|
+
const snapshot = useI18nSnapshot();
|
|
1223
|
+
const renderedRef = useRef(/* @__PURE__ */ new Set());
|
|
1224
|
+
renderedRef.current = /* @__PURE__ */ new Set();
|
|
1225
|
+
const tokenRef = useRef(/* @__PURE__ */ Symbol("sonenta.t"));
|
|
1226
|
+
const t2 = useMemo2(() => {
|
|
1227
|
+
const fn = (key, optionsOrDefault, maybeOptions) => {
|
|
1228
|
+
const fullKey = defaultNamespace && !key.includes(":") ? `${defaultNamespace}:${key}` : key;
|
|
1229
|
+
renderedRef.current.add(
|
|
1230
|
+
keyRegistry.encode(fullKey, i18n.defaultNamespace)
|
|
1231
|
+
);
|
|
1232
|
+
return i18n.t(fullKey, optionsOrDefault, maybeOptions);
|
|
1233
|
+
};
|
|
1234
|
+
return fn;
|
|
1235
|
+
}, [i18n, defaultNamespace]);
|
|
1236
|
+
useEffect2(() => {
|
|
1237
|
+
keyRegistry._set(tokenRef.current, renderedRef.current);
|
|
1238
|
+
});
|
|
1239
|
+
useEffect2(() => {
|
|
1240
|
+
const token = tokenRef.current;
|
|
1241
|
+
return () => keyRegistry._delete(token);
|
|
1242
|
+
}, []);
|
|
1243
|
+
return { t: t2, i18n: snapshot };
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// src/trans.tsx
|
|
1247
|
+
import { Children, cloneElement, isValidElement } from "react";
|
|
1248
|
+
import { Fragment as Fragment2, jsx as jsx2 } from "react/jsx-runtime";
|
|
1249
|
+
function Trans({
|
|
1250
|
+
i18nKey,
|
|
1251
|
+
defaults,
|
|
1252
|
+
values,
|
|
1253
|
+
components,
|
|
1254
|
+
namespace
|
|
1255
|
+
}) {
|
|
1256
|
+
const { t: t2 } = useTranslation(namespace);
|
|
1257
|
+
const raw = t2(i18nKey, { ...values ?? {}, defaultValue: defaults ?? i18nKey });
|
|
1258
|
+
if (!components || !components.length) return /* @__PURE__ */ jsx2(Fragment2, { children: raw });
|
|
1259
|
+
return /* @__PURE__ */ jsx2(Fragment2, { children: splitOnComponents(raw, components) });
|
|
1260
|
+
}
|
|
1261
|
+
function splitOnComponents(text, components) {
|
|
1262
|
+
const out = [];
|
|
1263
|
+
const re = /<(\d+)>(.*?)<\/\1>/g;
|
|
1264
|
+
let lastIndex = 0;
|
|
1265
|
+
let m;
|
|
1266
|
+
while ((m = re.exec(text)) !== null) {
|
|
1267
|
+
if (m.index > lastIndex) out.push(text.slice(lastIndex, m.index));
|
|
1268
|
+
const idx = Number(m[1]);
|
|
1269
|
+
const inner = m[2];
|
|
1270
|
+
const node = components[idx];
|
|
1271
|
+
if (isValidElement(node)) {
|
|
1272
|
+
out.push(
|
|
1273
|
+
cloneElement(node, { key: `t-${m.index}` }, ...Children.toArray(inner ?? ""))
|
|
1274
|
+
);
|
|
1275
|
+
} else if (node !== void 0) {
|
|
1276
|
+
out.push(node);
|
|
1277
|
+
} else {
|
|
1278
|
+
out.push(inner ?? "");
|
|
1279
|
+
}
|
|
1280
|
+
lastIndex = re.lastIndex;
|
|
1281
|
+
}
|
|
1282
|
+
if (lastIndex < text.length) out.push(text.slice(lastIndex));
|
|
1283
|
+
return out;
|
|
1284
|
+
}
|
|
1285
|
+
export {
|
|
1286
|
+
DEFAULT_SURFACE_BREAKPOINTS,
|
|
1287
|
+
SonentaProvider,
|
|
1288
|
+
Trans,
|
|
1289
|
+
SonentaProvider as VerbumiaProvider,
|
|
1290
|
+
defaultTransport,
|
|
1291
|
+
getI18n,
|
|
1292
|
+
getI18nSafe,
|
|
1293
|
+
keyRegistry,
|
|
1294
|
+
logTransport,
|
|
1295
|
+
surfaceForWidth,
|
|
1296
|
+
t,
|
|
1297
|
+
useTranslation
|
|
1298
|
+
};
|
|
1299
|
+
//# sourceMappingURL=index.js.map
|