@verbumia/react-i18next 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,8 +63,13 @@ interface VerbumiaConfig {
63
63
  defaultLocale: string; // BCP-47 (e.g. "fr", "fr-CA")
64
64
  fallbackLng?: string; // resolved before reporting a key as missing
65
65
  namespaces?: string[]; // default ['common']
66
- apiBase?: string; // default 'https://api.verbumia.ca'
66
+ defaultNS?: string; // alias: default namespace for single-ns apps
67
+ apiBase?: string; // default 'https://api.verbumia.dev'
67
68
  cdnBase?: string; // default 'https://cdn.verbumia.ca'
69
+ version?: string; // version slug, default 'main' (in cache keys)
70
+ versionSlug?: string; // @deprecated alias of `version`
71
+ env?: 'prod' | 'dev'; // default 'prod' (drives fetch source)
72
+ plugins?: VerbumiaPlugin[]; // e.g. @verbumia/feedback, @verbumia/realtime
68
73
  transport?: (batch: MissingKeyEvent[]) => void | Promise<void>;
69
74
  missingHandler?: 'send' | 'log' | 'off'; // default 'send'
70
75
  flushIntervalMs?: number; // default 5000
@@ -73,25 +78,58 @@ interface VerbumiaConfig {
73
78
  }
74
79
  ```
75
80
 
81
+ `version` selects which published version's bundles to load
82
+ (`/p/<project>/<version>/latest/...`); it defaults to `'main'` and is part
83
+ of the SDK's bundle cache keys, so two providers with different `version`
84
+ values never share cached bundles. `versionSlug` is a **deprecated** alias
85
+ of `version` (if both are set, `version` wins).
86
+
87
+ > **Removed in 0.9.0:** the `liveUpdates` / `centrifugoWsUrl` /
88
+ > `centrifugoTokenEndpoint` config keys. Realtime updates now live in the
89
+ > separate [`@verbumia/realtime`](../realtime) plugin (see
90
+ > [Realtime updates](#realtime-updates)). Passing any of those keys throws
91
+ > a clear migration error.
92
+ ```
93
+
76
94
  ### `useTranslation(defaultNamespace?)`
77
95
 
78
96
  Returns `{ t, i18n }`.
79
97
 
80
98
  ```ts
81
- type TranslationFunction = (
82
- key: string, // "ns:key.path" or "key.path"
83
- options?: Record<string, unknown> & { defaultValue?: string }
84
- ) => string;
99
+ // Two call shapes — the native object form AND the react-i18next-style
100
+ // positional fallback (a string 2nd arg is the default value):
101
+ type TranslationFunction = {
102
+ (key: string, defaultValue: string, options?: Record<string, unknown>): string;
103
+ (key: string, options?: Record<string, unknown> & { defaultValue?: string }): string;
104
+ };
85
105
 
86
106
  interface I18nInstance {
87
107
  ready: boolean;
88
108
  locale: string;
109
+ language: string; // alias of `locale`
89
110
  setLocale(next: string): Promise<void>;
111
+ changeLanguage(next: string): Promise<void>; // alias of `setLocale`
112
+ t: TranslationFunction; // for out-of-React use via getI18n()
90
113
  missingEvents: MissingKeyEvent[]; // newest first, capped buffer
91
114
  flushMissing(): Promise<void>; // force-flush the pending batch
115
+ reload(opts?: { locale?: string; namespace?: string }): Promise<void>;
92
116
  }
93
117
  ```
94
118
 
119
+ ### `i18n.reload(opts?)`
120
+
121
+ Bust-refetches already-loaded bundles (bypassing the browser HTTP cache)
122
+ and re-renders. Without `opts` it refreshes every loaded `(locale, ns)`
123
+ bundle; pass `{ locale }` and/or `{ namespace }` to narrow. Returns once
124
+ all refetches settle. Useful for a manual "refresh translations" button,
125
+ and it's what [`@verbumia/realtime`](#realtime-updates) calls on a
126
+ `translations_published` push.
127
+
128
+ ```ts
129
+ await getI18n().reload(); // refresh all
130
+ await getI18n().reload({ locale: "fr", namespace: "common" });
131
+ ```
132
+
95
133
  ### `<Trans>`
96
134
 
97
135
  Inline translation with JSX slots:
@@ -110,6 +148,41 @@ and the SDK swaps the elements at render time.
110
148
 
111
149
  ---
112
150
 
151
+ ## Migrating from react-i18next
152
+
153
+ `@verbumia/react-i18next` is built to be a near drop-in for react-i18next, so
154
+ existing codebases migrate with minimal changes:
155
+
156
+ - **Positional default value** — `t('key', 'Default text')` works (so does
157
+ `t('key', 'Hi {{name}}', { name })`), alongside the native
158
+ `t('key', { defaultValue })`. No codemod needed for inline fallbacks.
159
+ - **`changeLanguage` / `language`** — `i18n.changeLanguage('en')` (alias of
160
+ `setLocale`) and the `i18n.language` getter (alias of `locale`) are available.
161
+ - **Out-of-React access** — `getI18n()` returns the active instance for use in
162
+ plain modules, stores, or helpers (the react-i18next standalone-singleton
163
+ pattern):
164
+
165
+ ```ts
166
+ import { getI18n } from "@verbumia/react-i18next";
167
+ // anywhere after <VerbumiaProvider> has mounted:
168
+ const label = getI18n().t("nav.home", "Home");
169
+ await getI18n().changeLanguage("en");
170
+ ```
171
+
172
+ `getI18n()` throws a clear error if no provider is mounted yet, and assumes a
173
+ single app-wide provider.
174
+ - **Default namespace** — the default is `['common']` (not react-i18next's
175
+ `'translation'`). Migrants pass `namespaces={['translation']}`, or the
176
+ `defaultNS="translation"` alias for single-namespace apps.
177
+
178
+ ### Not yet supported (planned for V1.1)
179
+
180
+ Plurals and context are **not** resolved yet: `t('key', { count })` performs
181
+ interpolation only — it does **not** select plural keys (`key_one` /
182
+ `key_other`) or context keys (`key_male`). Handle these manually until V1.1.
183
+
184
+ ---
185
+
113
186
  ## Missing-key flow
114
187
 
115
188
  1. The user navigates a page that calls `t("hello.title")`.
@@ -171,6 +244,38 @@ import { defaultTransport, logTransport } from "@verbumia/react-i18next";
171
244
 
172
245
  ---
173
246
 
247
+ ## Realtime updates
248
+
249
+ Zero-deploy translation updates (subscribe to the project's Centrifugo
250
+ `translations:` channel and bust-refetch on publish) live in the separate
251
+ [`@verbumia/realtime`](../realtime) package — added as a **plugin** of this
252
+ provider, not configured here:
253
+
254
+ ```tsx
255
+ import { VerbumiaProvider } from "@verbumia/react-i18next";
256
+ import { verbumiaRealtime } from "@verbumia/realtime/react";
257
+
258
+ <VerbumiaProvider
259
+ {...config}
260
+ env="dev"
261
+ plugins={[
262
+ verbumiaRealtime({ wsUrl: "wss://centrifugo.verbumia.ca/connection/websocket" }),
263
+ ]}
264
+ >
265
+ <App />
266
+ </VerbumiaProvider>;
267
+ ```
268
+
269
+ Under the hood the plugin calls [`i18n.reload(...)`](#i18nreloadopts) on
270
+ each `translations_published` push. Realtime is a dev-version-only feature
271
+ (it only subscribes when `env: "dev"`).
272
+
273
+ > The `liveUpdates` / `centrifugoWsUrl` / `centrifugoTokenEndpoint` config
274
+ > keys were **removed in 0.9.0**. Install `@verbumia/realtime` and use the
275
+ > plugin instead.
276
+
277
+ ---
278
+
174
279
  ## Recipes
175
280
 
176
281
  ### Next.js (App Router)
package/dist/index.cjs CHANGED
@@ -23,6 +23,7 @@ __export(index_exports, {
23
23
  Trans: () => Trans,
24
24
  VerbumiaProvider: () => VerbumiaProvider,
25
25
  defaultTransport: () => defaultTransport,
26
+ getI18n: () => getI18n,
26
27
  keyRegistry: () => keyRegistry,
27
28
  logTransport: () => logTransport,
28
29
  useTranslation: () => useTranslation
@@ -74,111 +75,6 @@ var logTransport = (batch) => {
74
75
  }
75
76
  };
76
77
 
77
- // src/live.ts
78
- var LiveClient = class {
79
- constructor(cfg) {
80
- this.cfg = cfg;
81
- }
82
- cfg;
83
- _ws = null;
84
- _id = 0;
85
- _backoffMs = 1e3;
86
- _disposed = false;
87
- _connectAcked = false;
88
- /** Open the socket and try to subscribe. Idempotent — calling twice is a no-op. */
89
- async connect() {
90
- if (this._ws) return;
91
- if (this._disposed) return;
92
- this.cfg.onStatus?.("connecting");
93
- const token = this.cfg.refreshToken ? await this.cfg.refreshToken() : this.cfg.token;
94
- let url = this.cfg.url;
95
- if (!url.includes("/connection/websocket")) {
96
- url = url.replace(/\/+$/, "") + "/connection/websocket";
97
- }
98
- const ws = new WebSocket(url);
99
- this._ws = ws;
100
- this._connectAcked = false;
101
- ws.onopen = () => {
102
- this._send({ id: ++this._id, connect: { token } });
103
- };
104
- ws.onmessage = (evt) => this._onFrame(evt.data);
105
- ws.onclose = () => this._onClose();
106
- ws.onerror = () => {
107
- };
108
- }
109
- dispose() {
110
- this._disposed = true;
111
- if (this._ws) {
112
- try {
113
- this._ws.close();
114
- } catch {
115
- }
116
- this._ws = null;
117
- }
118
- this.cfg.onStatus?.("disconnected");
119
- }
120
- // ---- internals ----
121
- _send(msg) {
122
- if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
123
- try {
124
- this._ws.send(JSON.stringify(msg));
125
- } catch {
126
- }
127
- }
128
- _onFrame(raw) {
129
- let parsed;
130
- try {
131
- parsed = JSON.parse(raw);
132
- } catch {
133
- return;
134
- }
135
- if (!parsed || typeof parsed !== "object") return;
136
- if (Object.keys(parsed).length === 0) {
137
- this._send({});
138
- return;
139
- }
140
- if (parsed.connect && !this._connectAcked) {
141
- this._connectAcked = true;
142
- this.cfg.onStatus?.("connected");
143
- this._backoffMs = 1e3;
144
- this._send({
145
- id: ++this._id,
146
- subscribe: { channel: this.cfg.channel }
147
- });
148
- return;
149
- }
150
- const push = parsed.push;
151
- if (push && push.channel === this.cfg.channel && push.pub) {
152
- this.cfg.onMessage(push.pub.data);
153
- }
154
- }
155
- _onClose() {
156
- this._ws = null;
157
- this._connectAcked = false;
158
- this.cfg.onStatus?.("disconnected");
159
- if (this._disposed) return;
160
- const delay = this._backoffMs;
161
- this._backoffMs = Math.min(this._backoffMs * 2, 3e4);
162
- setTimeout(() => {
163
- if (!this._disposed) void this.connect();
164
- }, delay);
165
- }
166
- };
167
- async function fetchCentrifugoToken(endpoint, projectUuid, authToken, fetchImpl = fetch) {
168
- const r = await fetchImpl(endpoint, {
169
- method: "POST",
170
- headers: {
171
- "Content-Type": "application/json",
172
- Authorization: `ApiKey ${authToken}`
173
- },
174
- body: JSON.stringify({ project_uuid: projectUuid })
175
- });
176
- if (!r.ok) {
177
- throw new Error(`centrifugo-token endpoint ${r.status}: ${await r.text()}`);
178
- }
179
- return await r.json();
180
- }
181
-
182
78
  // src/key-registry.ts
183
79
  var GLOBAL = "__verbumia_key_registry__";
184
80
  var SEP = "\0";
@@ -316,10 +212,10 @@ var VerbumiaI18n = class {
316
212
  fallbackLng;
317
213
  missingEvents = [];
318
214
  _bundles = /* @__PURE__ */ new Map();
319
- // `${locale}/${ns}` -> tree
215
+ // `${version}/${locale}/${ns}` -> tree
320
216
  _attempted = /* @__PURE__ */ new Set();
321
- // `${locale}/${ns}` keys we've fetched
322
- // Tighter gate than `_attempted`: this set only contains (locale, ns)
217
+ // `${version}/${locale}/${ns}` keys we've fetched
218
+ // Tighter gate than `_attempted`: this set only contains (version, locale, ns)
323
219
  // pairs whose CDN response was 200 with at least one top-level key. An
324
220
  // empty bundle (404 → {} OR 200 → {}) is treated as "no data yet";
325
221
  // calling t() against a key in such a bundle does NOT fire reportMissing.
@@ -334,13 +230,20 @@ var VerbumiaI18n = class {
334
230
  // dedup `${locale}/${ns}/${key}` per-flush
335
231
  _timer = null;
336
232
  _listeners = /* @__PURE__ */ new Set();
337
- _live = null;
338
233
  // Stable snapshot reference for useSyncExternalStore. Returning a fresh
339
234
  // object on each getSnapshot call would loop React forever — we rebuild
340
235
  // it ONLY in _notify (when state actually changed) and return the cached
341
236
  // reference between notifications.
342
237
  _snapshot;
343
238
  constructor(config) {
239
+ const removedRealtimeKeys = Object.keys(config).filter(
240
+ (k) => k === "liveUpdates" || k.startsWith("centrifugo")
241
+ );
242
+ if (removedRealtimeKeys.length > 0) {
243
+ throw new Error(
244
+ `@verbumia/react-i18next: ${removedRealtimeKeys.join(", ")} ${removedRealtimeKeys.length > 1 ? "were" : "was"} removed in 0.9.0 \u2014 realtime is now the @verbumia/realtime plugin. Remove them and pass \`plugins: [verbumiaRealtime({ wsUrl })]\` to <VerbumiaProvider> instead.`
245
+ );
246
+ }
344
247
  this.locale = config.defaultLocale;
345
248
  this.fallbackLng = config.fallbackLng;
346
249
  this._config = {
@@ -349,14 +252,11 @@ var VerbumiaI18n = class {
349
252
  missingHandler: config.missingHandler ?? "send",
350
253
  token: config.token,
351
254
  projectUuid: config.projectUuid,
352
- namespaces: config.namespaces?.length ? config.namespaces : ["common"],
255
+ namespaces: config.namespaces?.length ? config.namespaces : config.defaultNS ? [config.defaultNS] : ["common"],
353
256
  flushIntervalMs: config.flushIntervalMs ?? DEFAULT_FLUSH_MS,
354
257
  flushBatchSize: config.flushBatchSize ?? DEFAULT_BATCH,
355
258
  missingEventsBufferSize: config.missingEventsBufferSize ?? DEFAULT_BUFFER,
356
- versionSlug: config.versionSlug ?? DEFAULT_VERSION_SLUG,
357
- liveUpdates: !!config.liveUpdates,
358
- centrifugoTokenEndpoint: config.centrifugoTokenEndpoint ?? `${(config.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, "")}/v1/auth/centrifugo-token`,
359
- centrifugoWsUrl: config.centrifugoWsUrl ?? "",
259
+ version: config.version ?? config.versionSlug ?? DEFAULT_VERSION_SLUG,
360
260
  env: config.env ?? "prod"
361
261
  };
362
262
  this._transport = config.transport ?? (this._config.missingHandler === "log" ? logTransport : defaultTransport({
@@ -378,11 +278,21 @@ var VerbumiaI18n = class {
378
278
  return {
379
279
  ready: this.ready,
380
280
  locale: this.locale,
281
+ language: this.locale,
381
282
  setLocale: this.setLocale,
283
+ changeLanguage: this.changeLanguage,
284
+ t: this.t,
382
285
  missingEvents: this.missingEvents,
383
- flushMissing: this.flushMissing
286
+ flushMissing: this.flushMissing,
287
+ reload: this.reload
384
288
  };
385
289
  }
290
+ /** Bundle cache-key builder. Includes `version` so providers with
291
+ * different `version` values never share cached bundles. The segments
292
+ * (version/locale/ns) are slugs and never contain '/'. */
293
+ _bundleKey(locale, ns) {
294
+ return `${this._config.version}/${locale}/${ns}`;
295
+ }
386
296
  _notify() {
387
297
  this._snapshot = this._buildSnapshot();
388
298
  for (const l of this._listeners) l();
@@ -405,9 +315,6 @@ var VerbumiaI18n = class {
405
315
  );
406
316
  this.ready = true;
407
317
  this._startTimer();
408
- if (this._config.liveUpdates && this._config.env === "dev") {
409
- this._startLive(fetchImpl);
410
- }
411
318
  this._notify();
412
319
  }
413
320
  setLocale = async (next) => {
@@ -421,102 +328,64 @@ var VerbumiaI18n = class {
421
328
  this.ready = true;
422
329
  this._notify();
423
330
  };
331
+ /** Alias of {@link setLocale} for react-i18next compatibility. */
332
+ changeLanguage = (next) => this.setLocale(next);
333
+ /** Alias of {@link locale} for react-i18next compatibility. */
334
+ get language() {
335
+ return this.locale;
336
+ }
424
337
  stop() {
425
338
  keyRegistry.detach();
426
339
  if (this._timer) {
427
340
  clearInterval(this._timer);
428
341
  this._timer = null;
429
342
  }
430
- if (this._live) {
431
- this._live.dispose();
432
- this._live = null;
433
- }
434
343
  }
435
344
  /**
436
- * Start the Centrifugo subscription and re-fetch the relevant bundle on
437
- * each `translations_published` event. Best-effort: if the WS URL or
438
- * token endpoint isn't reachable, we log silently and the SDK continues
439
- * to serve the initial bundle.
345
+ * Bust-refetch already-loaded bundles and re-render once. Generic
346
+ * replacement for the old realtime-only refetch: iterate the
347
+ * `_attempted` cache keys (`${version}/${locale}/${ns}`), optionally
348
+ * filtered by `opts.locale` / `opts.namespace`, and re-pull each one
349
+ * with `{ bust: true }` so the mutable CDN `latest/` alias bypasses the
350
+ * HTTP cache. After all settle, `_notify()` once so React re-renders.
351
+ *
352
+ * Used by `@verbumia/realtime` on a `translations_published` push and as
353
+ * a manual refresh hook. If nothing matches, returns without notifying.
440
354
  */
441
- _startLive(fetchImpl) {
442
- const wsUrl = this._config.centrifugoWsUrl;
443
- if (!wsUrl) {
444
- if (typeof console !== "undefined") {
445
- console.warn(
446
- "@verbumia/react-i18next: liveUpdates=true but centrifugoWsUrl is empty; skipping subscription."
447
- );
448
- }
449
- return;
355
+ reload = async (opts = {}) => {
356
+ const targets = [];
357
+ for (const key of this._attempted) {
358
+ const parts = key.split("/");
359
+ const locale = parts[1];
360
+ const ns = parts[2];
361
+ if (!locale || !ns) continue;
362
+ if (opts.locale && opts.locale !== locale) continue;
363
+ if (opts.namespace && opts.namespace !== ns) continue;
364
+ targets.push({ locale, ns });
450
365
  }
451
- const projectUuid = this._config.projectUuid;
452
- const tokenEndpoint = this._config.centrifugoTokenEndpoint;
453
- const apiToken = this._config.token;
454
- const refreshToken = async () => {
455
- const { token } = await fetchCentrifugoToken(
456
- tokenEndpoint,
457
- projectUuid,
458
- apiToken,
459
- fetchImpl
460
- );
461
- return token;
462
- };
463
- void (async () => {
464
- let channel;
465
- let token;
466
- try {
467
- const minted = await fetchCentrifugoToken(
468
- tokenEndpoint,
469
- projectUuid,
470
- apiToken,
471
- fetchImpl
472
- );
473
- channel = minted.channel;
474
- token = minted.token;
475
- } catch (err) {
476
- if (typeof console !== "undefined") {
477
- console.warn("@verbumia/react-i18next: live token mint failed", err);
478
- }
479
- return;
480
- }
481
- this._live = new LiveClient({
482
- url: wsUrl,
483
- token,
484
- channel,
485
- refreshToken,
486
- onMessage: (data) => this._onLiveMessage(data, fetchImpl)
487
- });
488
- void this._live.connect();
489
- })();
490
- }
491
- _onLiveMessage(data, fetchImpl) {
492
- if (!data || typeof data !== "object") return;
493
- const d = data;
494
- if (d.event !== "translations_published") return;
495
- const lang = d.language_code;
496
- const ns = d.namespace_slug;
497
- if (!lang || !ns) return;
498
- const cacheKey = `${lang}/${ns}`;
499
- if (!this._attempted.has(cacheKey)) return;
500
- void this._loadBundle(lang, ns, fetchImpl, { bust: true }).then(() => {
501
- this._notify();
502
- });
503
- }
366
+ if (targets.length === 0) return;
367
+ await Promise.all(
368
+ targets.map((t) => this._loadBundle(t.locale, t.ns, fetch, { bust: true }))
369
+ );
370
+ this._notify();
371
+ };
504
372
  // ---- Translation ----
505
- t = (key, options) => {
373
+ t = (key, optionsOrDefault, maybeOptions) => {
374
+ const options = typeof optionsOrDefault === "string" ? { ...maybeOptions ?? {}, defaultValue: optionsOrDefault } : optionsOrDefault;
506
375
  const namespace = this._splitNamespace(key);
507
376
  const bareKey = namespace.bareKey;
508
377
  const ns = namespace.ns;
509
- const fromActive = resolve(this._bundles.get(`${this.locale}/${ns}`), bareKey);
378
+ const fromActive = resolve(this._bundles.get(this._bundleKey(this.locale, ns)), bareKey);
510
379
  if (fromActive != null) {
511
380
  return this._render(fromActive, this.locale, options);
512
381
  }
513
382
  if (this.fallbackLng && this.fallbackLng !== this.locale) {
514
- const fb = resolve(this._bundles.get(`${this.fallbackLng}/${ns}`), bareKey);
383
+ const fb = resolve(this._bundles.get(this._bundleKey(this.fallbackLng, ns)), bareKey);
515
384
  if (fb != null) {
516
385
  return this._render(fb, this.fallbackLng, options);
517
386
  }
518
387
  }
519
- if (this.ready && this._attempted.has(`${this.locale}/${ns}`) && this._hasContent.has(`${this.locale}/${ns}`)) {
388
+ if (this.ready && this._attempted.has(this._bundleKey(this.locale, ns)) && this._hasContent.has(this._bundleKey(this.locale, ns))) {
520
389
  this._reportMissing({
521
390
  key: bareKey,
522
391
  namespace: ns,
@@ -563,13 +432,13 @@ var VerbumiaI18n = class {
563
432
  return { ns: this._config.namespaces[0], bareKey: key };
564
433
  }
565
434
  async _loadBundle(locale, ns, fetchImpl = fetch, opts = {}) {
566
- const cacheKey = `${locale}/${ns}`;
435
+ const cacheKey = this._bundleKey(locale, ns);
567
436
  let url;
568
437
  let init;
569
438
  if (this._config.env === "dev") {
570
439
  const params = new URLSearchParams({ language: locale, namespace: ns });
571
- if (this._config.versionSlug && this._config.versionSlug !== "main") {
572
- params.set("version_slug", this._config.versionSlug);
440
+ if (this._config.version && this._config.version !== "main") {
441
+ params.set("version_slug", this._config.version);
573
442
  }
574
443
  url = `${this._config.apiBase.replace(/\/+$/, "")}/v1/projects/${this._config.projectUuid}/translations/runtime?${params.toString()}`;
575
444
  init = {
@@ -578,7 +447,7 @@ var VerbumiaI18n = class {
578
447
  credentials: "omit"
579
448
  };
580
449
  } else {
581
- url = `${this._config.cdnBase.replace(/\/+$/, "")}/p/${this._config.projectUuid}/${this._config.versionSlug}/latest/${locale}/${ns}.json`;
450
+ url = `${this._config.cdnBase.replace(/\/+$/, "")}/p/${this._config.projectUuid}/${this._config.version}/latest/${locale}/${ns}.json`;
582
451
  init = { method: "GET", credentials: "omit" };
583
452
  }
584
453
  if (opts.bust) {
@@ -633,7 +502,7 @@ var VerbumiaI18n = class {
633
502
  return options.defaultValue;
634
503
  }
635
504
  if (this.fallbackLng && this.fallbackLng !== this.locale) {
636
- const fb = resolve(this._bundles.get(`${this.fallbackLng}/${ns}`), bareKey);
505
+ const fb = resolve(this._bundles.get(this._bundleKey(this.fallbackLng, ns)), bareKey);
637
506
  if (typeof fb === "string") {
638
507
  return fb;
639
508
  }
@@ -657,6 +526,23 @@ var VerbumiaI18n = class {
657
526
  }
658
527
  };
659
528
 
529
+ // src/singleton.ts
530
+ var _active = null;
531
+ function _setActiveInstance(instance) {
532
+ _active = instance;
533
+ }
534
+ function _clearActiveInstance(instance) {
535
+ if (_active === instance) _active = null;
536
+ }
537
+ function getI18n() {
538
+ if (!_active) {
539
+ throw new Error(
540
+ "@verbumia/react-i18next: getI18n() was called before <VerbumiaProvider> mounted (no active i18n instance)."
541
+ );
542
+ }
543
+ return _active;
544
+ }
545
+
660
546
  // src/provider.tsx
661
547
  var import_jsx_runtime = require("react/jsx-runtime");
662
548
  var VerbumiaContext = (0, import_react.createContext)(null);
@@ -666,11 +552,13 @@ function VerbumiaProvider({
666
552
  }) {
667
553
  const i18n = (0, import_react.useMemo)(() => new VerbumiaI18n(config), []);
668
554
  (0, import_react.useEffect)(() => {
555
+ _setActiveInstance(i18n);
669
556
  void i18n.start();
670
557
  const teardowns = (config.plugins ?? []).map((p) => p.setup?.({ i18n, config })).filter((t) => typeof t === "function");
671
558
  return () => {
672
559
  teardowns.forEach((t) => t());
673
560
  i18n.stop();
561
+ _clearActiveInstance(i18n);
674
562
  };
675
563
  }, [i18n]);
676
564
  const value = (0, import_react.useMemo)(() => ({ i18n }), [i18n]);
@@ -702,13 +590,14 @@ function useTranslation(defaultNamespace) {
702
590
  renderedRef.current = /* @__PURE__ */ new Set();
703
591
  const tokenRef = (0, import_react2.useRef)(/* @__PURE__ */ Symbol("verbumia.t"));
704
592
  const t = (0, import_react2.useMemo)(() => {
705
- return (key, options) => {
593
+ const fn = (key, optionsOrDefault, maybeOptions) => {
706
594
  const fullKey = defaultNamespace && !key.includes(":") ? `${defaultNamespace}:${key}` : key;
707
595
  renderedRef.current.add(
708
596
  keyRegistry.encode(fullKey, i18n.defaultNamespace)
709
597
  );
710
- return i18n.t(fullKey, options);
598
+ return i18n.t(fullKey, optionsOrDefault, maybeOptions);
711
599
  };
600
+ return fn;
712
601
  }, [i18n, defaultNamespace]);
713
602
  (0, import_react2.useEffect)(() => {
714
603
  keyRegistry._set(tokenRef.current, renderedRef.current);
@@ -764,6 +653,7 @@ function splitOnComponents(text, components) {
764
653
  Trans,
765
654
  VerbumiaProvider,
766
655
  defaultTransport,
656
+ getI18n,
767
657
  keyRegistry,
768
658
  logTransport,
769
659
  useTranslation