@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/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